From 5596a52dcb13484f7fdc71d15383cb5b144849ae Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 5 Apr 2026 12:45:45 +0900 Subject: [PATCH] =?UTF-8?q?Commands/Dictionary=20=EB=AF=B8=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=B2=84=EA=B7=B8=204=EA=B1=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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로 분기) --- src/main/services/VoiceModeService.ts | 12 ++- src/preload/index.ts | 12 +++ src/renderer/pages/CommandsPage.tsx | 135 +++++++++++++++++++++----- src/renderer/pages/DictionaryPage.tsx | 70 +++++++++---- 4 files changed, 185 insertions(+), 44 deletions(-) diff --git a/src/main/services/VoiceModeService.ts b/src/main/services/VoiceModeService.ts index dafaa33..4e01a93 100644 --- a/src/main/services/VoiceModeService.ts +++ b/src/main/services/VoiceModeService.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index c3a03ca..8d52edb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -231,6 +231,18 @@ const electronAPI = { getVersion: () => invoke(IPC_CHANNELS.SYSTEM.GET_VERSION), checkMicPermission: () => invoke(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION) + }, + + // ── Instruction (커스텀 명령어) ──────────────────────── + instruction: { + getAll: () => invoke('instruction:getAll'), + getById: (params: { id: string }) => invoke('instruction:getById', params), + create: (params: { name: string; description: string; prompt: string }) => + invoke('instruction:create', params), + update: (params: { id: string; name?: string; description?: string; prompt?: string }) => + invoke('instruction:update', params), + delete: (params: { id: string }) => invoke('instruction:delete', params), + reorder: (params: { ids: string[] }) => invoke('instruction:reorder', params), } } as const diff --git a/src/renderer/pages/CommandsPage.tsx b/src/renderer/pages/CommandsPage.tsx index dc8d2fa..327e58c 100644 --- a/src/renderer/pages/CommandsPage.tsx +++ b/src/renderer/pages/CommandsPage.tsx @@ -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 & { - invoke: (channel: string, ...args: unknown[]) => Promise> - }).invoke?.('instruction:getAll') as unknown as IPCResult | 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 ( @@ -46,7 +89,9 @@ export function CommandsPage(): React.ReactElement { LLM INSTRUCTIONS — {instructions.length} COMMANDS - + {loading ? ( @@ -54,7 +99,7 @@ export function CommandsPage(): React.ReactElement { ) : instructions.length === 0 ? ( - BUILT-IN: TRANSLATE, SUMMARIZE, FORMAL, CODE, FREE + NO COMMANDS — CLICK ADD TO CREATE ) : ( @@ -65,16 +110,28 @@ export function CommandsPage(): React.ReactElement { - {inst.name} - {inst.description} + + {inst.name} + + + {inst.description} + - openEdit(inst)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}> + openEdit(inst)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }} + > {!inst.isBuiltin && ( - + handleDelete(inst.id)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }} + > )} @@ -86,15 +143,43 @@ export function CommandsPage(): React.ReactElement { )} setDialogOpen(false)} maxWidth="sm" fullWidth> - {editId ? 'Edit Command' : 'Add Command'} + + {editId ? '명령어 편집' : '명령어 추가'} + - setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> - setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} /> - setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText="Use {{text}} for transcribed text" /> + setFormName(e.target.value)} + fullWidth + autoFocus + sx={{ mt: 1 }} + /> + setFormDesc(e.target.value)} + fullWidth + sx={{ mt: 2 }} + /> + setFormPrompt(e.target.value)} + fullWidth + multiline + rows={4} + sx={{ mt: 2 }} + helperText="{{text}}는 전사된 텍스트로 치환됩니다" + /> - - + + diff --git a/src/renderer/pages/DictionaryPage.tsx b/src/renderer/pages/DictionaryPage.tsx index 64f7ead..4c18b61 100644 --- a/src/renderer/pages/DictionaryPage.tsx +++ b/src/renderer/pages/DictionaryPage.tsx @@ -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(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(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 { CUSTOM DICTIONARY — {data?.total ?? 0} WORDS - @@ -81,25 +109,31 @@ export function DictionaryPage(): React.ReactElement { {entry.category.toUpperCase()} · {entry.usageCount}× USED - { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }} - sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}> - - + + openEdit(entry)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}> + + + { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}> + + + ))} )} - setAddOpen(false)} maxWidth="xs" fullWidth> - Add Word + setDialogOpen(false)} maxWidth="xs" fullWidth> + {editId ? '단어 편집' : '단어 추가'} - setNewWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> - setNewPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} /> + setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> + setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} /> - - + +