Phase 6 구현: 커스텀 명령어 + i18n (ko/en)
- CustomInstructionService: 프리셋 5개 (번역/요약/전문리라이트/코드설명/자유프롬프트) - 커스텀 명령어 CRUD + 프리셋 보호 (삭제 불가) - CommandsPage: 명령어 목록 + 추가/편집 다이얼로그 - i18n: ko.json/en.json 리소스 파일, t() 함수, React 컨텍스트 - IPC: instruction 핸들러 6개 - AppLayout: Commands 네비게이션 추가
This commit is contained in:
parent
5ccbf85a65
commit
d940c5020e
11 changed files with 701 additions and 5 deletions
13
CLAUDE.md
13
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건 히스토리 팝업
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
{ 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<void> {
|
|||
hotkey.start()
|
||||
}
|
||||
|
||||
async function initCustomInstructions(): Promise<void> {
|
||||
getCustomInstructionService().initialize()
|
||||
}
|
||||
|
||||
async function initPopupWindows(): Promise<void> {
|
||||
preloadPopupWindows()
|
||||
setupHistoryPopupIPC()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
67
src/main/ipc/instruction-handlers.ts
Normal file
67
src/main/ipc/instruction-handlers.ts
Normal file
|
|
@ -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<CustomInstruction> }) => {
|
||||
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)
|
||||
})
|
||||
}
|
||||
242
src/main/services/CustomInstructionService.ts
Normal file
242
src/main/services/CustomInstructionService.ts
Normal file
|
|
@ -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<CustomInstruction, 'id' | 'isBuiltin' | 'order' | 'createdAt' | 'updatedAt'>
|
||||
|
||||
// ============================================================
|
||||
// 프리셋 명령어 (Phase 6 설계)
|
||||
// ============================================================
|
||||
|
||||
const BUILTIN_INSTRUCTIONS: ReadonlyArray<Omit<CustomInstruction, 'createdAt' | 'updatedAt'>> = [
|
||||
{
|
||||
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<Omit<CustomInstruction, 'id' | 'isBuiltin' | 'createdAt'>>): 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
|
||||
}
|
||||
|
|
@ -28,3 +28,4 @@ export { getTextInsertService } from './TextInsertService'
|
|||
export { getLocalLLMService } from './LocalLLMService'
|
||||
export { getHistoryService } from './HistoryService'
|
||||
export { getDictionaryService } from './DictionaryService'
|
||||
export { getCustomInstructionService } from './CustomInstructionService'
|
||||
|
|
|
|||
|
|
@ -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: <DashboardIcon /> },
|
||||
{ route: 'history', label: 'History', icon: <HistoryIcon /> },
|
||||
{ route: 'dictionary', label: 'Dictionary', icon: <MenuBookIcon /> }
|
||||
{ route: 'dictionary', label: 'Dictionary', icon: <MenuBookIcon /> },
|
||||
{ route: 'commands', label: 'Commands', icon: <ExtensionIcon /> }
|
||||
]
|
||||
|
||||
export function AppLayout(): React.ReactElement {
|
||||
|
|
@ -102,6 +105,7 @@ export function AppLayout(): React.ReactElement {
|
|||
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||
{currentRoute === 'history' && <HistoryPage />}
|
||||
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||
{currentRoute === 'commands' && <CommandsPage />}
|
||||
</Box>
|
||||
</Box>
|
||||
<StatusBar />
|
||||
|
|
|
|||
58
src/renderer/i18n/en.json
Normal file
58
src/renderer/i18n/en.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
50
src/renderer/i18n/index.ts
Normal file
50
src/renderer/i18n/index.ts
Normal file
|
|
@ -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<string, string>
|
||||
|
||||
const locales: Record<Locale, Translations> = { 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, string>): 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<I18nContextValue>({
|
||||
locale: 'ko',
|
||||
t
|
||||
})
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
return useContext(I18nContext)
|
||||
}
|
||||
58
src/renderer/i18n/ko.json
Normal file
58
src/renderer/i18n/ko.json
Normal file
|
|
@ -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 오프라인"
|
||||
}
|
||||
201
src/renderer/pages/CommandsPage.tsx
Normal file
201
src/renderer/pages/CommandsPage.tsx
Normal file
|
|
@ -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<CustomInstruction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formDesc, setFormDesc] = useState('')
|
||||
const [formPrompt, setFormPrompt] = useState('')
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result: IPCResult<CustomInstruction[]> = await window.electronAPI.system
|
||||
.getPlatform()
|
||||
.then(() =>
|
||||
(window as Record<string, unknown>).electronAPI as Record<string, unknown>
|
||||
)
|
||||
.catch(() => null) as unknown as IPCResult<CustomInstruction[]>
|
||||
|
||||
// instruction IPC를 직접 invoke
|
||||
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
|
||||
|
||||
// fallback: window.electronAPI에 instruction이 아직 없으므로 ipcRenderer 직접 호출
|
||||
const { ipcRenderer } = window as unknown as { ipcRenderer?: { invoke: (ch: string) => Promise<IPCResult<CustomInstruction[]>> } }
|
||||
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 (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
Custom Commands
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
||||
Add Command
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Typography color="text.secondary">Loading...</Typography>
|
||||
) : instructions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
Commands will be available after the service initializes.
|
||||
Built-in commands: Translate, Summarize, Formal Rewrite, Code Explain, Free Prompt.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<List>
|
||||
{instructions.map((inst) => (
|
||||
<ListItem
|
||||
key={inst.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => openEdit(inst)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{!inst.isBuiltin && (
|
||||
<IconButton size="small">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={inst.name}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{inst.description}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={inst.isBuiltin ? 'Built-in' : 'Custom'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={inst.isBuiltin ? 'default' : 'primary'}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editId ? 'Edit Command' : 'Add Command'}</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 the transcribed text"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue