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:
parent
6e9e8b1f29
commit
5596a52dcb
4 changed files with 185 additions and 44 deletions
|
|
@ -418,8 +418,18 @@ class VoiceModeService extends EventEmitter {
|
|||
const stt = getLocalSTTService()
|
||||
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, {
|
||||
language: language === 'auto' ? undefined : language
|
||||
language: language === 'auto' ? undefined : language,
|
||||
initialPrompt,
|
||||
})
|
||||
|
||||
if (this._isInTerminalState()) return
|
||||
|
|
|
|||
|
|
@ -231,6 +231,18 @@ const electronAPI = {
|
|||
getVersion: () => invoke<string>(IPC_CHANNELS.SYSTEM.GET_VERSION),
|
||||
checkMicPermission: () =>
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// src/renderer/pages/CommandsPage.tsx
|
||||
// 인스트루먼트 미학: MetalCard + PhosphorText + Led
|
||||
// 커스텀 LLM 명령어 관리: CRUD + 프리셋 5개
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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 EditIcon from '@mui/icons-material/Edit'
|
||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||
import { d3roPalette } from '../theme'
|
||||
import type { IPCResult } from '@shared/errors'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
|
||||
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 {
|
||||
|
|
@ -25,20 +30,58 @@ export function CommandsPage(): React.ReactElement {
|
|||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const ipcResult = await (window.electronAPI as Record<string, unknown> & {
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<IPCResult<CustomInstruction[]>>
|
||||
}).invoke?.('instruction:getAll') as unknown as IPCResult<CustomInstruction[]> | undefined
|
||||
if (ipcResult && ipcResult.success) setInstructions(ipcResult.data)
|
||||
} catch { /* noop */ }
|
||||
const result = await window.electronAPI.instruction.getAll()
|
||||
if (result.success) {
|
||||
setInstructions(result.data as CustomInstruction[])
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const openAdd = () => { setEditId(null); setFormName(''); setFormDesc(''); setFormPrompt(''); setDialogOpen(true) }
|
||||
const openEdit = (inst: CustomInstruction) => { setEditId(inst.id); setFormName(inst.name); setFormDesc(inst.description); setFormPrompt(inst.prompt); setDialogOpen(true) }
|
||||
const handleSave = async () => { setDialogOpen(false); loadData() }
|
||||
const openAdd = () => {
|
||||
setEditId(null)
|
||||
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 (
|
||||
<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 }}>
|
||||
LLM INSTRUCTIONS — {instructions.length} COMMANDS
|
||||
</PhosphorText>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">ADD</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
||||
ADD
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -54,7 +99,7 @@ export function CommandsPage(): React.ReactElement {
|
|||
) : instructions.length === 0 ? (
|
||||
<MetalCard>
|
||||
<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>
|
||||
</MetalCard>
|
||||
) : (
|
||||
|
|
@ -65,16 +110,28 @@ export function CommandsPage(): React.ReactElement {
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
||||
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
|
||||
<Box>
|
||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{inst.name}</Box>
|
||||
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>{inst.description}</Box>
|
||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>
|
||||
{inst.name}
|
||||
</Box>
|
||||
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>
|
||||
{inst.description}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<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 }} />
|
||||
</IconButton>
|
||||
{!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 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -86,15 +143,43 @@ export function CommandsPage(): React.ReactElement {
|
|||
)}
|
||||
|
||||
<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>
|
||||
<TextField label="Name" value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||
<TextField label="Description" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||
<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" />
|
||||
<TextField
|
||||
label="이름"
|
||||
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>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)} color="secondary" variant="contained">Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>Save</Button>
|
||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||
import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, InputAdornment } from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
|
|
@ -16,9 +17,10 @@ export function DictionaryPage(): React.ReactElement {
|
|||
const [data, setData] = useState<DictPageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [newWord, setNewWord] = useState('')
|
||||
const [newPronunciation, setNewPronunciation] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [formWord, setFormWord] = useState('')
|
||||
const [formPronunciation, setFormPronunciation] = useState('')
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -31,10 +33,36 @@ export function DictionaryPage(): React.ReactElement {
|
|||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newWord.trim()) return
|
||||
await window.electronAPI.dictionary.add({ word: newWord.trim(), pronunciation: newPronunciation.trim() || undefined })
|
||||
setNewWord(''); setNewPronunciation(''); setAddOpen(false); loadData()
|
||||
const openAdd = () => {
|
||||
setEditId(null)
|
||||
setFormWord('')
|
||||
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 (
|
||||
|
|
@ -43,7 +71,7 @@ export function DictionaryPage(): React.ReactElement {
|
|||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||||
CUSTOM DICTIONARY — {data?.total ?? 0} WORDS
|
||||
</PhosphorText>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)} size="small">
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
||||
ADD
|
||||
</Button>
|
||||
</Box>
|
||||
|
|
@ -81,25 +109,31 @@ export function DictionaryPage(): React.ReactElement {
|
|||
{entry.category.toUpperCase()} · {entry.usageCount}× USED
|
||||
</Box>
|
||||
</Box>
|
||||
<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 sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => openEdit(entry)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
||||
<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>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Add Word</DialogTitle>
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? '단어 편집' : '단어 추가'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="Word" value={newWord} onChange={(e) => setNewWord(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={formWord} onChange={(e) => setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||
<TextField label="발음 (선택)" value={formPronunciation} onChange={(e) => setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setAddOpen(false)} color="secondary" variant="contained">Cancel</Button>
|
||||
<Button onClick={handleAdd} variant="contained" disabled={!newWord.trim()}>Add</Button>
|
||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>취소</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formWord.trim()}>저장</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue