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:
Yun Chan 2026-04-05 02:53:32 +09:00
parent 5ccbf85a65
commit d940c5020e
11 changed files with 701 additions and 5 deletions

View file

@ -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건 히스토리 팝업

View file

@ -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()

View file

@ -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')
}

View 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)
})
}

View 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
}

View file

@ -28,3 +28,4 @@ export { getTextInsertService } from './TextInsertService'
export { getLocalLLMService } from './LocalLLMService'
export { getHistoryService } from './HistoryService'
export { getDictionaryService } from './DictionaryService'
export { getCustomInstructionService } from './CustomInstructionService'

View file

@ -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
View 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"
}

View 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
View 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 오프라인"
}

View 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>
)
}