d3ro-voice/apps/desktop/src/main/services/CustomInstructionService.ts
Yun Chan 6ba25f53b7 fix(desktop): surface configuration and provider failures instead of hiding them
Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
2026-09-16 23:23:58 +09:00

241 lines
7.2 KiB
TypeScript

// src/main/services/CustomInstructionService.ts
// 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조.
// electron-store에 저장, 프리셋 5개 기본 제공.
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { CustomInstruction } from '@d3ro/core/types'
const logger = getLogger('CustomInstructionService')
type CreateInput = Omit<
CustomInstruction,
'id' | 'isBuiltin' | 'order' | 'createdAt' | 'updatedAt' | 'icon'
> & { icon?: string }
// ============================================================
// 프리셋 명령어 (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 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 {
configSet('customInstructions', instructions)
} 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 {
if (!input.name.trim()) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction name is empty')
}
if (!input.prompt.trim()) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction prompt is empty')
}
const now = Date.now()
const instruction: CustomInstruction = {
id: crypto.randomUUID(),
name: input.name.trim(),
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
}
export function resetCustomInstructionServiceForTests(): void {
instance = null
initialized = false
instructions = []
}