Commands/Dictionary 미연결 버그 4건 수정

1. CommandsPage: handleSave/handleDelete에 실제 IPC 호출 연결
   - instruction:create/update/delete 호출 추가
   - 프리셋은 삭제 불가 (isBuiltin 체크)

2. Preload: instruction API 추가 (getAll/create/update/delete/reorder)
   - 기존에 preload 브릿지가 없어서 렌더러에서 호출 불가했음

3. Dictionary → STT initialPrompt 주입
   - VoiceModeService._transcribe에서 DictionaryService.getPromptHints() 호출
   - 사용자 사전 단어를 Whisper initialPrompt로 전달

4. DictionaryPage: 편집 기능 추가
   - 각 항목에 Edit 버튼 추가
   - Add/Edit 공용 다이얼로그 (editId로 분기)
This commit is contained in:
Yun Chan 2026-04-05 12:45:45 +09:00
parent 6e9e8b1f29
commit 5596a52dcb
4 changed files with 185 additions and 44 deletions

View file

@ -418,8 +418,18 @@ class VoiceModeService extends EventEmitter {
const stt = getLocalSTTService() const stt = getLocalSTTService()
const language = configGet('sttLanguage') const language = configGet('sttLanguage')
// Dictionary → STT initialPrompt 주입 (Speakly 패턴)
let initialPrompt: string | undefined
try {
const { getDictionaryService } = await import('./DictionaryService')
initialPrompt = getDictionaryService().getPromptHints() || undefined
} catch {
// DictionaryService 미초기화 시 무시
}
const result: TranscriptionResult = await stt.transcribe(merged, { const result: TranscriptionResult = await stt.transcribe(merged, {
language: language === 'auto' ? undefined : language language: language === 'auto' ? undefined : language,
initialPrompt,
}) })
if (this._isInTerminalState()) return if (this._isInTerminalState()) return

View file

@ -231,6 +231,18 @@ const electronAPI = {
getVersion: () => invoke<string>(IPC_CHANNELS.SYSTEM.GET_VERSION), getVersion: () => invoke<string>(IPC_CHANNELS.SYSTEM.GET_VERSION),
checkMicPermission: () => checkMicPermission: () =>
invoke<PermissionStatus>(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION) invoke<PermissionStatus>(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION)
},
// ── Instruction (커스텀 명령어) ────────────────────────
instruction: {
getAll: () => invoke<unknown[]>('instruction:getAll'),
getById: (params: { id: string }) => invoke<unknown>('instruction:getById', params),
create: (params: { name: string; description: string; prompt: string }) =>
invoke<unknown>('instruction:create', params),
update: (params: { id: string; name?: string; description?: string; prompt?: string }) =>
invoke<unknown>('instruction:update', params),
delete: (params: { id: string }) => invoke<void>('instruction:delete', params),
reorder: (params: { ids: string[] }) => invoke<void>('instruction:reorder', params),
} }
} as const } as const

View file

@ -1,5 +1,5 @@
// src/renderer/pages/CommandsPage.tsx // src/renderer/pages/CommandsPage.tsx
// 인스트루먼트 미학: MetalCard + PhosphorText + Led // 커스텀 LLM 명령어 관리: CRUD + 프리셋 5개
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material' import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material'
@ -7,11 +7,16 @@ import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import { MetalCard, PhosphorText, Led } from '../components/ds' import { MetalCard, PhosphorText, Led } from '../components/ds'
import { d3roPalette } from '../theme' import { d3roPalette, d3roFontMono } from '../theme'
import type { IPCResult } from '@shared/errors'
interface CustomInstruction { interface CustomInstruction {
id: string; name: string; description: string; prompt: string; icon: string; isBuiltin: boolean; order: number id: string
name: string
description: string
prompt: string
icon: string
isBuiltin: boolean
order: number
} }
export function CommandsPage(): React.ReactElement { export function CommandsPage(): React.ReactElement {
@ -25,20 +30,58 @@ export function CommandsPage(): React.ReactElement {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
try { const result = await window.electronAPI.instruction.getAll()
const ipcResult = await (window.electronAPI as Record<string, unknown> & { if (result.success) {
invoke: (channel: string, ...args: unknown[]) => Promise<IPCResult<CustomInstruction[]>> setInstructions(result.data as CustomInstruction[])
}).invoke?.('instruction:getAll') as unknown as IPCResult<CustomInstruction[]> | undefined }
if (ipcResult && ipcResult.success) setInstructions(ipcResult.data)
} catch { /* noop */ }
setLoading(false) setLoading(false)
}, []) }, [])
useEffect(() => { loadData() }, [loadData]) useEffect(() => { loadData() }, [loadData])
const openAdd = () => { setEditId(null); setFormName(''); setFormDesc(''); setFormPrompt(''); setDialogOpen(true) } const openAdd = () => {
const openEdit = (inst: CustomInstruction) => { setEditId(inst.id); setFormName(inst.name); setFormDesc(inst.description); setFormPrompt(inst.prompt); setDialogOpen(true) } setEditId(null)
const handleSave = async () => { setDialogOpen(false); loadData() } setFormName('')
setFormDesc('')
setFormPrompt('{{text}}를 다듬어주세요.')
setDialogOpen(true)
}
const openEdit = (inst: CustomInstruction) => {
setEditId(inst.id)
setFormName(inst.name)
setFormDesc(inst.description)
setFormPrompt(inst.prompt)
setDialogOpen(true)
}
const handleSave = async () => {
if (!formName.trim()) return
if (editId) {
// 편집
await window.electronAPI.instruction.update({
id: editId,
name: formName.trim(),
description: formDesc.trim(),
prompt: formPrompt.trim(),
})
} else {
// 추가
await window.electronAPI.instruction.create({
name: formName.trim(),
description: formDesc.trim(),
prompt: formPrompt.trim(),
})
}
setDialogOpen(false)
loadData()
}
const handleDelete = async (id: string) => {
await window.electronAPI.instruction.delete({ id })
loadData()
}
return ( return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}> <Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
@ -46,7 +89,9 @@ export function CommandsPage(): React.ReactElement {
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}> <PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
LLM INSTRUCTIONS {instructions.length} COMMANDS LLM INSTRUCTIONS {instructions.length} COMMANDS
</PhosphorText> </PhosphorText>
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">ADD</Button> <Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
ADD
</Button>
</Box> </Box>
{loading ? ( {loading ? (
@ -54,7 +99,7 @@ export function CommandsPage(): React.ReactElement {
) : instructions.length === 0 ? ( ) : instructions.length === 0 ? (
<MetalCard> <MetalCard>
<Box sx={{ py: 6, textAlign: 'center' }}> <Box sx={{ py: 6, textAlign: 'center' }}>
<PhosphorText variant="dim">BUILT-IN: TRANSLATE, SUMMARIZE, FORMAL, CODE, FREE</PhosphorText> <PhosphorText variant="dim">NO COMMANDS CLICK ADD TO CREATE</PhosphorText>
</Box> </Box>
</MetalCard> </MetalCard>
) : ( ) : (
@ -65,16 +110,28 @@ export function CommandsPage(): React.ReactElement {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} /> <Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
<Box> <Box>
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{inst.name}</Box> <Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>{inst.description}</Box> {inst.name}
</Box>
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>
{inst.description}
</Box>
</Box> </Box>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => openEdit(inst)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}> <IconButton
size="small"
onClick={() => openEdit(inst)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<EditIcon sx={{ fontSize: 16 }} /> <EditIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
{!inst.isBuiltin && ( {!inst.isBuiltin && (
<IconButton size="small" sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}> <IconButton
size="small"
onClick={() => handleDelete(inst.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
>
<DeleteIcon sx={{ fontSize: 16 }} /> <DeleteIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
)} )}
@ -86,15 +143,43 @@ export function CommandsPage(): React.ReactElement {
)} )}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? 'Edit Command' : 'Add Command'}</DialogTitle> <DialogTitle sx={{ fontWeight: 700 }}>
{editId ? '명령어 편집' : '명령어 추가'}
</DialogTitle>
<DialogContent> <DialogContent>
<TextField label="Name" value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> <TextField
<TextField label="Description" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} /> label="이름"
<TextField label="Prompt Template" value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText="Use {{text}} for transcribed text" /> value={formName}
onChange={(e) => setFormName(e.target.value)}
fullWidth
autoFocus
sx={{ mt: 1 }}
/>
<TextField
label="설명"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
fullWidth
sx={{ mt: 2 }}
/>
<TextField
label="프롬프트 템플릿"
value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)}
fullWidth
multiline
rows={4}
sx={{ mt: 2 }}
helperText="{{text}}는 전사된 텍스트로 치환됩니다"
/>
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} color="secondary" variant="contained">Cancel</Button> <Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>Save</Button>
</Button>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>

View file

@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from 'react'
import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, InputAdornment } from '@mui/material' import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, InputAdornment } from '@mui/material'
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import SearchIcon from '@mui/icons-material/Search' import SearchIcon from '@mui/icons-material/Search'
import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds' import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds'
import { d3roPalette, d3roFontMono } from '../theme' import { d3roPalette, d3roFontMono } from '../theme'
@ -16,9 +17,10 @@ export function DictionaryPage(): React.ReactElement {
const [data, setData] = useState<DictPageData | null>(null) const [data, setData] = useState<DictPageData | null>(null)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [addOpen, setAddOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [newWord, setNewWord] = useState('') const [editId, setEditId] = useState<string | null>(null)
const [newPronunciation, setNewPronunciation] = useState('') const [formWord, setFormWord] = useState('')
const [formPronunciation, setFormPronunciation] = useState('')
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
@ -31,10 +33,36 @@ export function DictionaryPage(): React.ReactElement {
useEffect(() => { loadData() }, [loadData]) useEffect(() => { loadData() }, [loadData])
const handleAdd = async () => { const openAdd = () => {
if (!newWord.trim()) return setEditId(null)
await window.electronAPI.dictionary.add({ word: newWord.trim(), pronunciation: newPronunciation.trim() || undefined }) setFormWord('')
setNewWord(''); setNewPronunciation(''); setAddOpen(false); loadData() setFormPronunciation('')
setDialogOpen(true)
}
const openEdit = (entry: DictionaryEntry) => {
setEditId(entry.id)
setFormWord(entry.word)
setFormPronunciation(entry.pronunciation ?? '')
setDialogOpen(true)
}
const handleSave = async () => {
if (!formWord.trim()) return
if (editId) {
await window.electronAPI.dictionary.update({
id: editId,
word: formWord.trim(),
pronunciation: formPronunciation.trim() || undefined,
})
} else {
await window.electronAPI.dictionary.add({
word: formWord.trim(),
pronunciation: formPronunciation.trim() || undefined,
})
}
setDialogOpen(false)
loadData()
} }
return ( return (
@ -43,7 +71,7 @@ export function DictionaryPage(): React.ReactElement {
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}> <PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
CUSTOM DICTIONARY {data?.total ?? 0} WORDS CUSTOM DICTIONARY {data?.total ?? 0} WORDS
</PhosphorText> </PhosphorText>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)} size="small"> <Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
ADD ADD
</Button> </Button>
</Box> </Box>
@ -81,25 +109,31 @@ export function DictionaryPage(): React.ReactElement {
{entry.category.toUpperCase()} · {entry.usageCount}× USED {entry.category.toUpperCase()} · {entry.usageCount}× USED
</Box> </Box>
</Box> </Box>
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }} <Box sx={{ display: 'flex', gap: 0.5 }}>
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}> <IconButton size="small" onClick={() => openEdit(entry)}
<DeleteIcon sx={{ fontSize: 16 }} /> sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
</IconButton> <EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box> </Box>
</MetalCard> </MetalCard>
))} ))}
</Box> </Box>
)} )}
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>Add Word</DialogTitle> <DialogTitle sx={{ fontWeight: 700 }}>{editId ? '단어 편집' : '단어 추가'}</DialogTitle>
<DialogContent> <DialogContent>
<TextField label="Word" value={newWord} onChange={(e) => setNewWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> <TextField label="단어" value={formWord} onChange={(e) => setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
<TextField label="Pronunciation (optional)" value={newPronunciation} onChange={(e) => setNewPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} /> <TextField label="발음 (선택)" value={formPronunciation} onChange={(e) => setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} />
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setAddOpen(false)} color="secondary" variant="contained">Cancel</Button> <Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}></Button>
<Button onClick={handleAdd} variant="contained" disabled={!newWord.trim()}>Add</Button> <Button onClick={handleSave} variant="contained" disabled={!formWord.trim()}></Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>