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 애니메이션
|
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
Phase: 5 + 3.5 완료
|
Phase: 6 완료 (Phase 1~6 + 3.5 전체 완료)
|
||||||
마지막 완료: Phase 3.5 — 커서 위치 히스토리 팝업 (D3RO 고유 기능)
|
마지막 완료: Phase 6 — 커스텀 명령어 + i18n (ko/en)
|
||||||
다음 작업: Phase 6 — 커스텀 명령어 + 설정 UI 고도화 + i18n
|
다음 작업: Phase 7 — 테스트 + 빌드 + 배포 (electron-builder, CI/CD)
|
||||||
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
|
차단 이슈: 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 구현 내용
|
### Phase 3.5 구현 내용
|
||||||
- HistoryPopup: Vanilla JS 팝업, 다크 카드(#242427), 앰버 악센트(#f25b29)
|
- HistoryPopup: Vanilla JS 팝업, 다크 카드(#242427), 앰버 악센트(#f25b29)
|
||||||
- Ctrl+Shift+V 글로벌 단축키 → 커서 위치에 최근 10건 히스토리 팝업
|
- Ctrl+Shift+V 글로벌 단축키 → 커서 위치에 최근 10건 히스토리 팝업
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { getVoiceModeService } from './services/VoiceModeService'
|
||||||
import { getLocalLLMService } from './services/LocalLLMService'
|
import { getLocalLLMService } from './services/LocalLLMService'
|
||||||
import { getHistoryService } from './services/HistoryService'
|
import { getHistoryService } from './services/HistoryService'
|
||||||
import { getTextInsertService } from './services/TextInsertService'
|
import { getTextInsertService } from './services/TextInsertService'
|
||||||
|
import { getCustomInstructionService } from './services/CustomInstructionService'
|
||||||
import { initDatabase } from './db'
|
import { initDatabase } from './db'
|
||||||
import {
|
import {
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
|
|
@ -41,6 +42,7 @@ export async function bootstrap(): Promise<void> {
|
||||||
{ name: 'create-windows', critical: true, fn: createWindows },
|
{ name: 'create-windows', critical: true, fn: createWindows },
|
||||||
{ name: 'tray', critical: false, fn: initTray },
|
{ name: 'tray', critical: false, fn: initTray },
|
||||||
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
||||||
|
{ name: 'custom-instructions', critical: false, fn: initCustomInstructions },
|
||||||
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
||||||
{ name: 'hotkey', critical: false, fn: initHotkey },
|
{ name: 'hotkey', critical: false, fn: initHotkey },
|
||||||
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
||||||
|
|
@ -95,6 +97,10 @@ async function initHotkey(): Promise<void> {
|
||||||
hotkey.start()
|
hotkey.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initCustomInstructions(): Promise<void> {
|
||||||
|
getCustomInstructionService().initialize()
|
||||||
|
}
|
||||||
|
|
||||||
async function initPopupWindows(): Promise<void> {
|
async function initPopupWindows(): Promise<void> {
|
||||||
preloadPopupWindows()
|
preloadPopupWindows()
|
||||||
setupHistoryPopupIPC()
|
setupHistoryPopupIPC()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { registerHotkeyHandlers } from './hotkey-handlers'
|
||||||
import { registerLLMHandlers } from './llm-handlers'
|
import { registerLLMHandlers } from './llm-handlers'
|
||||||
import { registerHistoryHandlers } from './history-handlers'
|
import { registerHistoryHandlers } from './history-handlers'
|
||||||
import { registerDictionaryHandlers } from './dictionary-handlers'
|
import { registerDictionaryHandlers } from './dictionary-handlers'
|
||||||
|
import { registerInstructionHandlers } from './instruction-handlers'
|
||||||
import { getLogger } from '../services/LoggerService'
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
|
||||||
const logger = getLogger('ipc')
|
const logger = getLogger('ipc')
|
||||||
|
|
@ -25,5 +26,6 @@ export function registerAllIpcHandlers(): void {
|
||||||
registerLLMHandlers()
|
registerLLMHandlers()
|
||||||
registerHistoryHandlers()
|
registerHistoryHandlers()
|
||||||
registerDictionaryHandlers()
|
registerDictionaryHandlers()
|
||||||
|
registerInstructionHandlers()
|
||||||
logger.info('All IPC handlers registered')
|
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 { getLocalLLMService } from './LocalLLMService'
|
||||||
export { getHistoryService } from './HistoryService'
|
export { getHistoryService } from './HistoryService'
|
||||||
export { getDictionaryService } from './DictionaryService'
|
export { getDictionaryService } from './DictionaryService'
|
||||||
|
export { getCustomInstructionService } from './CustomInstructionService'
|
||||||
|
|
|
||||||
|
|
@ -15,21 +15,24 @@ import {
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||||
import HistoryIcon from '@mui/icons-material/History'
|
import HistoryIcon from '@mui/icons-material/History'
|
||||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||||
|
import ExtensionIcon from '@mui/icons-material/Extension'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import { DashboardPage } from '../pages/DashboardPage'
|
import { DashboardPage } from '../pages/DashboardPage'
|
||||||
import { HistoryPage } from '../pages/HistoryPage'
|
import { HistoryPage } from '../pages/HistoryPage'
|
||||||
import { DictionaryPage } from '../pages/DictionaryPage'
|
import { DictionaryPage } from '../pages/DictionaryPage'
|
||||||
|
import { CommandsPage } from '../pages/CommandsPage'
|
||||||
import { SettingsModal } from './SettingsModal'
|
import { SettingsModal } from './SettingsModal'
|
||||||
import { StatusBar } from './StatusBar'
|
import { StatusBar } from './StatusBar'
|
||||||
|
|
||||||
type Route = 'dashboard' | 'history' | 'dictionary'
|
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands'
|
||||||
|
|
||||||
const DRAWER_WIDTH = 240
|
const DRAWER_WIDTH = 240
|
||||||
|
|
||||||
const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [
|
const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [
|
||||||
{ route: 'dashboard', label: 'Dashboard', icon: <DashboardIcon /> },
|
{ route: 'dashboard', label: 'Dashboard', icon: <DashboardIcon /> },
|
||||||
{ route: 'history', label: 'History', icon: <HistoryIcon /> },
|
{ 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 {
|
export function AppLayout(): React.ReactElement {
|
||||||
|
|
@ -102,6 +105,7 @@ export function AppLayout(): React.ReactElement {
|
||||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||||
{currentRoute === 'history' && <HistoryPage />}
|
{currentRoute === 'history' && <HistoryPage />}
|
||||||
{currentRoute === 'dictionary' && <DictionaryPage />}
|
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||||
|
{currentRoute === 'commands' && <CommandsPage />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<StatusBar />
|
<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