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.
241 lines
7.2 KiB
TypeScript
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 = []
|
|
}
|