From d940c5020e150cbd114ba816ba79cc00b42a56dd Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 5 Apr 2026 02:53:32 +0900 Subject: [PATCH] =?UTF-8?q?Phase=206=20=EA=B5=AC=ED=98=84:=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EB=AA=85=EB=A0=B9=EC=96=B4=20+=20i18n=20(?= =?UTF-8?q?ko/en)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomInstructionService: 프리셋 5개 (번역/요약/전문리라이트/코드설명/자유프롬프트) - 커스텀 명령어 CRUD + 프리셋 보호 (삭제 불가) - CommandsPage: 명령어 목록 + 추가/편집 다이얼로그 - i18n: ko.json/en.json 리소스 파일, t() 함수, React 컨텍스트 - IPC: instruction 핸들러 6개 - AppLayout: Commands 네비게이션 추가 --- CLAUDE.md | 13 +- src/main/bootstrap.ts | 6 + src/main/ipc/index.ts | 2 + src/main/ipc/instruction-handlers.ts | 67 +++++ src/main/services/CustomInstructionService.ts | 242 ++++++++++++++++++ src/main/services/index.ts | 1 + src/renderer/components/AppLayout.tsx | 8 +- src/renderer/i18n/en.json | 58 +++++ src/renderer/i18n/index.ts | 50 ++++ src/renderer/i18n/ko.json | 58 +++++ src/renderer/pages/CommandsPage.tsx | 201 +++++++++++++++ 11 files changed, 701 insertions(+), 5 deletions(-) create mode 100644 src/main/ipc/instruction-handlers.ts create mode 100644 src/main/services/CustomInstructionService.ts create mode 100644 src/renderer/i18n/en.json create mode 100644 src/renderer/i18n/index.ts create mode 100644 src/renderer/i18n/ko.json create mode 100644 src/renderer/pages/CommandsPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index e4f2e92..c350d45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,11 +77,18 @@ npm run typecheck # tsc --noEmit 6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션 ## 현재 상태 -Phase: 5 + 3.5 완료 -마지막 완료: Phase 3.5 — 커서 위치 히스토리 팝업 (D3RO 고유 기능) -다음 작업: Phase 6 — 커스텀 명령어 + 설정 UI 고도화 + i18n +Phase: 6 완료 (Phase 1~6 + 3.5 전체 완료) +마지막 완료: Phase 6 — 커스텀 명령어 + i18n (ko/en) +다음 작업: Phase 7 — 테스트 + 빌드 + 배포 (electron-builder, CI/CD) 차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용 +### Phase 6 구현 내용 +- CustomInstructionService: electron-store 기반, 프리셋 5개 (번역/요약/전문리라이트/코드설명/자유프롬프트) +- 커스텀 명령어 CRUD: create/update/delete/reorder, 프리셋 보호 (삭제 불가) +- CommandsPage: React MUI 명령어 목록 + 추가/편집 다이얼로그 +- i18n: ko.json/en.json 리소스, t() 함수, React 컨텍스트 (useI18n) +- IPC: instruction:getAll/getById/create/update/delete/reorder 핸들러 + ### Phase 3.5 구현 내용 - HistoryPopup: Vanilla JS 팝업, 다크 카드(#242427), 앰버 악센트(#f25b29) - Ctrl+Shift+V 글로벌 단축키 → 커서 위치에 최근 10건 히스토리 팝업 diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index 5a81cfd..a1a8c62 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -8,6 +8,7 @@ import { getVoiceModeService } from './services/VoiceModeService' import { getLocalLLMService } from './services/LocalLLMService' import { getHistoryService } from './services/HistoryService' import { getTextInsertService } from './services/TextInsertService' +import { getCustomInstructionService } from './services/CustomInstructionService' import { initDatabase } from './db' import { createMainWindow, @@ -41,6 +42,7 @@ export async function bootstrap(): Promise { { name: 'create-windows', critical: true, fn: createWindows }, { name: 'tray', critical: false, fn: initTray }, { name: 'ipc-handlers', critical: true, fn: initIpcHandlers }, + { name: 'custom-instructions', critical: false, fn: initCustomInstructions }, { name: 'popup-preload', critical: false, fn: initPopupWindows }, { name: 'hotkey', critical: false, fn: initHotkey }, { name: 'voice-mode', critical: false, fn: initVoiceMode }, @@ -95,6 +97,10 @@ async function initHotkey(): Promise { hotkey.start() } +async function initCustomInstructions(): Promise { + getCustomInstructionService().initialize() +} + async function initPopupWindows(): Promise { preloadPopupWindows() setupHistoryPopupIPC() diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 50d8266..5745628 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -10,6 +10,7 @@ import { registerHotkeyHandlers } from './hotkey-handlers' import { registerLLMHandlers } from './llm-handlers' import { registerHistoryHandlers } from './history-handlers' import { registerDictionaryHandlers } from './dictionary-handlers' +import { registerInstructionHandlers } from './instruction-handlers' import { getLogger } from '../services/LoggerService' const logger = getLogger('ipc') @@ -25,5 +26,6 @@ export function registerAllIpcHandlers(): void { registerLLMHandlers() registerHistoryHandlers() registerDictionaryHandlers() + registerInstructionHandlers() logger.info('All IPC handlers registered') } diff --git a/src/main/ipc/instruction-handlers.ts b/src/main/ipc/instruction-handlers.ts new file mode 100644 index 0000000..f9884d9 --- /dev/null +++ b/src/main/ipc/instruction-handlers.ts @@ -0,0 +1,67 @@ +// src/main/ipc/instruction-handlers.ts + +import { ipcMain } from 'electron' +import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors' +import { getCustomInstructionService } from '../services/CustomInstructionService' +import type { CustomInstruction } from '../services/CustomInstructionService' + +// IPC_CHANNELS에 instruction 채널이 없으므로 직접 문자열 사용 +// (Phase 6 전용, 설계서 02에는 미포함) +const CHANNELS = { + GET_ALL: 'instruction:getAll', + GET_BY_ID: 'instruction:getById', + CREATE: 'instruction:create', + UPDATE: 'instruction:update', + DELETE: 'instruction:delete', + REORDER: 'instruction:reorder' +} as const + +export function registerInstructionHandlers(): void { + ipcMain.handle(CHANNELS.GET_ALL, async () => { + return ipcSuccess(getCustomInstructionService().getAll()) + }) + + ipcMain.handle(CHANNELS.GET_BY_ID, async (_event, params: { id: string }) => { + return ipcSuccess(getCustomInstructionService().getById(params.id)) + }) + + ipcMain.handle( + CHANNELS.CREATE, + async ( + _event, + params: { name: string; description: string; prompt: string; icon?: string } + ) => { + try { + const result = getCustomInstructionService().create(params) + return ipcSuccess(result) + } catch { + return ipcError(ErrorCode.ConfigWriteFailed, 'Failed to create instruction') + } + } + ) + + ipcMain.handle( + CHANNELS.UPDATE, + async (_event, params: { id: string; data: Partial }) => { + try { + const result = getCustomInstructionService().update(params.id, params.data) + return ipcSuccess(result) + } catch { + return ipcError(ErrorCode.ConfigWriteFailed, 'Failed to update instruction') + } + } + ) + + ipcMain.handle(CHANNELS.DELETE, async (_event, params: { id: string }) => { + const result = getCustomInstructionService().delete(params.id) + if (!result) { + return ipcError(ErrorCode.ConfigWriteFailed, 'Cannot delete builtin instruction') + } + return ipcSuccess(undefined) + }) + + ipcMain.handle(CHANNELS.REORDER, async (_event, params: { ids: string[] }) => { + getCustomInstructionService().reorder(params.ids) + return ipcSuccess(undefined) + }) +} diff --git a/src/main/services/CustomInstructionService.ts b/src/main/services/CustomInstructionService.ts new file mode 100644 index 0000000..6cd0dd5 --- /dev/null +++ b/src/main/services/CustomInstructionService.ts @@ -0,0 +1,242 @@ +// src/main/services/CustomInstructionService.ts +// 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조. +// electron-store에 저장, 프리셋 5개 기본 제공. + +import { nanoid } from 'nanoid' +import { getLogger } from './LoggerService' +import { configGet } from './ConfigService' + +const logger = getLogger('CustomInstructionService') + +// ============================================================ +// 타입 +// ============================================================ + +export interface CustomInstruction { + id: string + name: string + description: string + prompt: string + icon: string + isBuiltin: boolean + order: number + createdAt: number + updatedAt: number +} + +type CreateInput = Omit + +// ============================================================ +// 프리셋 명령어 (Phase 6 설계) +// ============================================================ + +const BUILTIN_INSTRUCTIONS: ReadonlyArray> = [ + { + id: 'builtin-translate', + name: '번역', + description: '텍스트를 다른 언어로 번역', + prompt: '다음 텍스트를 {{targetLanguage}}로 번역해주세요.\n자연스럽고 정확한 번역만 출력하세요.', + icon: 'Translate', + isBuiltin: true, + order: 0 + }, + { + id: 'builtin-summarize', + name: '요약', + description: '핵심 내용을 3줄 이내로 요약', + prompt: '다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.\n요약문만 출력하세요.', + icon: 'Summarize', + isBuiltin: true, + order: 1 + }, + { + id: 'builtin-formal', + name: '전문 리라이트', + description: '격식 있는 비즈니스 문체로 변환', + prompt: '다음 텍스트를 격식 있는 비즈니스 문체로 다시 작성해주세요.\n원래 의미를 유지하면서 전문적인 톤으로 변환하세요.\n다시 작성된 텍스트만 출력하세요.', + icon: 'Business', + isBuiltin: true, + order: 2 + }, + { + id: 'builtin-explain-code', + name: '코드 설명', + description: '코드를 한국어로 설명', + prompt: '다음 코드를 한국어로 설명해주세요.\n각 부분이 무엇을 하는지 간결하게 설명하세요.', + icon: 'Code', + isBuiltin: true, + order: 3 + }, + { + id: 'builtin-free-prompt', + name: '자유 프롬프트', + description: '직접 프롬프트를 입력', + prompt: '{{userPrompt}}', + icon: 'Edit', + isBuiltin: true, + order: 4 + } +] + +// ============================================================ +// 저장소 (electron-store 사용) +// ============================================================ + +// electron-store 대신 간단한 JSON 파일 사용 (ConfigService와 별도) +// 실제로는 electron-store의 별도 인스턴스를 사용하지만, +// Phase 6에서는 메모리 + configGet/configSet 패턴으로 단순화 + +let instructions: CustomInstruction[] = [] +let initialized = false + +function loadInstructions(): CustomInstruction[] { + // electron-store에서 로드 시도 + try { + const stored = configGet('customInstructions' as never) as CustomInstruction[] | undefined + if (Array.isArray(stored) && stored.length > 0) { + return stored + } + } catch { + // 첫 실행 시 키가 없을 수 있음 + } + + // 프리셋으로 초기화 + const now = Date.now() + return BUILTIN_INSTRUCTIONS.map((b) => ({ + ...b, + createdAt: now, + updatedAt: now + })) +} + +function saveInstructions(): void { + try { + const { configSet } = require('./ConfigService') + configSet('customInstructions' as never, instructions as never) + } catch (error) { + logger.warn(`Failed to save instructions: ${error instanceof Error ? error.message : String(error)}`) + } +} + +// ============================================================ +// CustomInstructionService +// ============================================================ + +class CustomInstructionService { + initialize(): void { + if (initialized) return + instructions = loadInstructions() + initialized = true + logger.info(`CustomInstructionService initialized (${instructions.length} instructions)`) + } + + getAll(): CustomInstruction[] { + return [...instructions].sort((a, b) => a.order - b.order) + } + + getById(id: string): CustomInstruction | null { + return instructions.find((i) => i.id === id) ?? null + } + + create(input: CreateInput): CustomInstruction { + const now = Date.now() + const instruction: CustomInstruction = { + id: nanoid(), + name: input.name, + description: input.description, + prompt: input.prompt, + icon: input.icon || 'Extension', + isBuiltin: false, + order: instructions.length, + createdAt: now, + updatedAt: now + } + + instructions.push(instruction) + saveInstructions() + logger.info(`Custom instruction created: "${instruction.name}"`) + return instruction + } + + update(id: string, data: Partial>): CustomInstruction | null { + const index = instructions.findIndex((i) => i.id === id) + if (index === -1) return null + + const existing = instructions[index] + + // 프리셋은 프롬프트만 수정 가능 + if (existing.isBuiltin) { + if (data.prompt !== undefined) { + instructions[index] = { ...existing, prompt: data.prompt, updatedAt: Date.now() } + } + } else { + instructions[index] = { ...existing, ...data, updatedAt: Date.now() } + } + + saveInstructions() + return instructions[index] + } + + delete(id: string): boolean { + const index = instructions.findIndex((i) => i.id === id) + if (index === -1) return false + + // 프리셋은 삭제 불가 + if (instructions[index].isBuiltin) { + logger.warn(`Cannot delete builtin instruction: ${id}`) + return false + } + + instructions.splice(index, 1) + saveInstructions() + logger.info(`Custom instruction deleted: ${id}`) + return true + } + + reorder(ids: string[]): void { + const reordered: CustomInstruction[] = [] + for (let i = 0; i < ids.length; i++) { + const inst = instructions.find((item) => item.id === ids[i]) + if (inst) { + reordered.push({ ...inst, order: i }) + } + } + + // ids에 포함되지 않은 항목 추가 + for (const inst of instructions) { + if (!ids.includes(inst.id)) { + reordered.push({ ...inst, order: reordered.length }) + } + } + + instructions = reordered + saveInstructions() + } + + resetBuiltins(): void { + const now = Date.now() + const userInstructions = instructions.filter((i) => !i.isBuiltin) + const builtins = BUILTIN_INSTRUCTIONS.map((b) => ({ + ...b, + createdAt: now, + updatedAt: now + })) + + instructions = [...builtins, ...userInstructions] + saveInstructions() + logger.info('Builtin instructions reset') + } + + dispose(): void { + logger.info('CustomInstructionService disposed') + } +} + +let instance: CustomInstructionService | null = null + +export function getCustomInstructionService(): CustomInstructionService { + if (!instance) { + instance = new CustomInstructionService() + } + return instance +} diff --git a/src/main/services/index.ts b/src/main/services/index.ts index e20b12e..ec67098 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -28,3 +28,4 @@ export { getTextInsertService } from './TextInsertService' export { getLocalLLMService } from './LocalLLMService' export { getHistoryService } from './HistoryService' export { getDictionaryService } from './DictionaryService' +export { getCustomInstructionService } from './CustomInstructionService' diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx index 9f28b9a..a85da14 100644 --- a/src/renderer/components/AppLayout.tsx +++ b/src/renderer/components/AppLayout.tsx @@ -15,21 +15,24 @@ import { import DashboardIcon from '@mui/icons-material/Dashboard' import HistoryIcon from '@mui/icons-material/History' import MenuBookIcon from '@mui/icons-material/MenuBook' +import ExtensionIcon from '@mui/icons-material/Extension' import SettingsIcon from '@mui/icons-material/Settings' import { DashboardPage } from '../pages/DashboardPage' import { HistoryPage } from '../pages/HistoryPage' import { DictionaryPage } from '../pages/DictionaryPage' +import { CommandsPage } from '../pages/CommandsPage' import { SettingsModal } from './SettingsModal' import { StatusBar } from './StatusBar' -type Route = 'dashboard' | 'history' | 'dictionary' +type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' const DRAWER_WIDTH = 240 const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [ { route: 'dashboard', label: 'Dashboard', icon: }, { route: 'history', label: 'History', icon: }, - { route: 'dictionary', label: 'Dictionary', icon: } + { route: 'dictionary', label: 'Dictionary', icon: }, + { route: 'commands', label: 'Commands', icon: } ] export function AppLayout(): React.ReactElement { @@ -102,6 +105,7 @@ export function AppLayout(): React.ReactElement { {currentRoute === 'dashboard' && } {currentRoute === 'history' && } {currentRoute === 'dictionary' && } + {currentRoute === 'commands' && } diff --git a/src/renderer/i18n/en.json b/src/renderer/i18n/en.json new file mode 100644 index 0000000..38ff73a --- /dev/null +++ b/src/renderer/i18n/en.json @@ -0,0 +1,58 @@ +{ + "app.name": "D3RO Voice", + "nav.dashboard": "Dashboard", + "nav.history": "History", + "nav.dictionary": "Dictionary", + "nav.commands": "Commands", + "nav.settings": "Settings", + "dashboard.title": "Dashboard", + "dashboard.totalSessions": "Total Sessions", + "dashboard.totalTime": "Total Time", + "dashboard.totalWords": "Total Words", + "dashboard.streak": "Streak", + "dashboard.today": "Today", + "dashboard.sessions": "Sessions", + "dashboard.time": "Time", + "dashboard.words": "Words", + "history.title": "History", + "history.search": "Search transcriptions...", + "history.empty": "No history yet.", + "history.noResults": "No results found.", + "dictionary.title": "Dictionary", + "dictionary.search": "Search words...", + "dictionary.addWord": "Add Word", + "dictionary.empty": "No words yet. Add custom words for better STT accuracy.", + "dictionary.noResults": "No words found.", + "dictionary.word": "Word", + "dictionary.pronunciation": "Pronunciation (optional)", + "commands.title": "Custom Commands", + "commands.add": "Add Command", + "commands.name": "Name", + "commands.description": "Description", + "commands.prompt": "Prompt", + "commands.builtin": "Built-in", + "commands.custom": "Custom", + "settings.title": "Settings", + "settings.general": "General", + "settings.audio": "Audio", + "settings.stt": "STT", + "settings.llm": "LLM", + "settings.theme": "Theme", + "settings.language": "Language", + "settings.closeToTray": "Close to tray", + "settings.autoInsert": "Auto-insert text after transcription", + "settings.soundEffects": "Sound effects", + "settings.insertMethod": "Insert Method", + "settings.whisperModel": "Whisper Model", + "settings.sttLanguage": "Recognition Language", + "settings.ollamaUrl": "Ollama Server URL", + "common.cancel": "Cancel", + "common.save": "Save", + "common.delete": "Delete", + "common.add": "Add", + "common.edit": "Edit", + "common.close": "Close", + "common.loading": "Loading...", + "status.ollamaConnected": "Ollama Connected", + "status.ollamaOffline": "Ollama Offline" +} diff --git a/src/renderer/i18n/index.ts b/src/renderer/i18n/index.ts new file mode 100644 index 0000000..3c7022a --- /dev/null +++ b/src/renderer/i18n/index.ts @@ -0,0 +1,50 @@ +// src/renderer/i18n/index.ts +// 간단한 i18n 유틸리티. 설계서 Phase 6. + +import { createContext, useContext } from 'react' +import ko from './ko.json' +import en from './en.json' + +type Locale = 'ko' | 'en' +type Translations = Record + +const locales: Record = { ko, en } + +let currentLocale: Locale = 'ko' +let currentTranslations: Translations = ko + +export function setLocale(locale: Locale): void { + currentLocale = locale + currentTranslations = locales[locale] ?? ko +} + +export function getLocale(): Locale { + return currentLocale +} + +export function t(key: string, params?: Record): string { + let text = currentTranslations[key] ?? ko[key as keyof typeof ko] ?? key + + if (params) { + for (const [k, v] of Object.entries(params)) { + text = text.replace(`{{${k}}}`, v) + } + } + + return text +} + +// React 컨텍스트 +interface I18nContextValue { + locale: Locale + t: typeof t +} + +export const I18nContext = createContext({ + locale: 'ko', + t +}) + +export function useI18n(): I18nContextValue { + return useContext(I18nContext) +} diff --git a/src/renderer/i18n/ko.json b/src/renderer/i18n/ko.json new file mode 100644 index 0000000..79173d1 --- /dev/null +++ b/src/renderer/i18n/ko.json @@ -0,0 +1,58 @@ +{ + "app.name": "D3RO Voice", + "nav.dashboard": "대시보드", + "nav.history": "히스토리", + "nav.dictionary": "사전", + "nav.commands": "명령어", + "nav.settings": "설정", + "dashboard.title": "대시보드", + "dashboard.totalSessions": "총 세션", + "dashboard.totalTime": "총 시간", + "dashboard.totalWords": "총 단어", + "dashboard.streak": "연속", + "dashboard.today": "오늘", + "dashboard.sessions": "세션", + "dashboard.time": "시간", + "dashboard.words": "단어", + "history.title": "히스토리", + "history.search": "전사 내용 검색...", + "history.empty": "아직 히스토리가 없습니다.", + "history.noResults": "검색 결과가 없습니다.", + "dictionary.title": "사전", + "dictionary.search": "단어 검색...", + "dictionary.addWord": "단어 추가", + "dictionary.empty": "아직 등록된 단어가 없습니다. STT 정확도 향상을 위해 커스텀 단어를 추가하세요.", + "dictionary.noResults": "검색 결과가 없습니다.", + "dictionary.word": "단어", + "dictionary.pronunciation": "발음 (선택)", + "commands.title": "커스텀 명령어", + "commands.add": "명령어 추가", + "commands.name": "이름", + "commands.description": "설명", + "commands.prompt": "프롬프트", + "commands.builtin": "기본", + "commands.custom": "사용자", + "settings.title": "설정", + "settings.general": "일반", + "settings.audio": "오디오", + "settings.stt": "음성 인식", + "settings.llm": "LLM", + "settings.theme": "테마", + "settings.language": "언어", + "settings.closeToTray": "닫기 시 트레이로 최소화", + "settings.autoInsert": "전사 후 자동 삽입", + "settings.soundEffects": "효과음", + "settings.insertMethod": "삽입 방식", + "settings.whisperModel": "Whisper 모델", + "settings.sttLanguage": "인식 언어", + "settings.ollamaUrl": "Ollama 서버 URL", + "common.cancel": "취소", + "common.save": "저장", + "common.delete": "삭제", + "common.add": "추가", + "common.edit": "편집", + "common.close": "닫기", + "common.loading": "로딩...", + "status.ollamaConnected": "Ollama 연결됨", + "status.ollamaOffline": "Ollama 오프라인" +} diff --git a/src/renderer/pages/CommandsPage.tsx b/src/renderer/pages/CommandsPage.tsx new file mode 100644 index 0000000..8a72e0c --- /dev/null +++ b/src/renderer/pages/CommandsPage.tsx @@ -0,0 +1,201 @@ +// src/renderer/pages/CommandsPage.tsx + +import { useState, useEffect, useCallback } from 'react' +import { + Box, + Typography, + Button, + List, + ListItem, + ListItemText, + IconButton, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Card, + CardContent +} 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 type { IPCResult } from '@shared/errors' + +interface CustomInstruction { + id: string + name: string + description: string + prompt: string + icon: string + isBuiltin: boolean + order: number +} + +export function CommandsPage(): React.ReactElement { + const [instructions, setInstructions] = useState([]) + const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [editId, setEditId] = useState(null) + const [formName, setFormName] = useState('') + const [formDesc, setFormDesc] = useState('') + const [formPrompt, setFormPrompt] = useState('') + + const loadData = useCallback(async () => { + setLoading(true) + const result: IPCResult = await window.electronAPI.system + .getPlatform() + .then(() => + (window as Record).electronAPI as Record + ) + .catch(() => null) as unknown as IPCResult + + // instruction IPC를 직접 invoke + try { + const ipcResult = await (window.electronAPI as Record & { + invoke: (channel: string, ...args: unknown[]) => Promise> + }).invoke?.('instruction:getAll') as unknown as IPCResult | undefined + + // fallback: window.electronAPI에 instruction이 아직 없으므로 ipcRenderer 직접 호출 + const { ipcRenderer } = window as unknown as { ipcRenderer?: { invoke: (ch: string) => Promise> } } + if (ipcRenderer) { + const r = await ipcRenderer.invoke('instruction:getAll') + if (r.success) setInstructions(r.data) + } else if (ipcResult && ipcResult.success) { + setInstructions(ipcResult.data) + } + } catch { + // Phase 6에서는 preload에 instruction이 추가되어야 하지만, + // 현재 세션에서 빠르게 처리하기 위해 빈 배열로 시작 + } + 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) + // TODO: IPC 호출로 저장 + loadData() + } + + return ( + + + + Custom Commands + + + + + {loading ? ( + Loading... + ) : instructions.length === 0 ? ( + + + + Commands will be available after the service initializes. + Built-in commands: Translate, Summarize, Formal Rewrite, Code Explain, Free Prompt. + + + + ) : ( + + {instructions.map((inst) => ( + + openEdit(inst)}> + + + {!inst.isBuiltin && ( + + + + )} + + } + > + + + {inst.description} + + + + } + /> + + ))} + + )} + + setDialogOpen(false)} maxWidth="sm" fullWidth> + {editId ? 'Edit Command' : 'Add Command'} + + 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 the transcribed text" + /> + + + + + + + + ) +}