커맨드 활성화 기능: 클릭하면 다음 녹음에 적용
- CommandsPage: 명령어 카드 클릭 → 활성 명령어 설정 (앰버 하이라이트)
- 상단에 ACTIVE COMMAND 표시
- 같은 카드 다시 클릭하면 해제
- 활성화 시 defaultLLMAction='custom' + activeInstructionId 저장
- VoiceModeService: action='custom'일 때 활성 명령어 프롬프트로 LLM 처리
- {{text}}를 전사 텍스트로 치환
This commit is contained in:
parent
8bd9d11ecd
commit
fea923d302
2 changed files with 118 additions and 73 deletions
|
|
@ -468,9 +468,27 @@ class VoiceModeService extends EventEmitter {
|
||||||
const llm = getLocalLLMService()
|
const llm = getLocalLLMService()
|
||||||
const action = configGet('defaultLLMAction')
|
const action = configGet('defaultLLMAction')
|
||||||
|
|
||||||
logger.info(`Processing with LLM (action: ${action})`)
|
let processedText: string
|
||||||
|
|
||||||
const processedText = await llm.processText(transcribedText, action)
|
if (action === 'custom') {
|
||||||
|
// 활성 명령어의 프롬프트를 사용
|
||||||
|
const activeId = configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string
|
||||||
|
let customPrompt = transcribedText
|
||||||
|
|
||||||
|
if (activeId) {
|
||||||
|
const { getCustomInstructionService } = await import('./CustomInstructionService')
|
||||||
|
const instruction = getCustomInstructionService().getById(activeId)
|
||||||
|
if (instruction) {
|
||||||
|
customPrompt = instruction.prompt.replace(/\{\{text\}\}/g, transcribedText)
|
||||||
|
logger.info(`Using custom instruction: "${instruction.name}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedText = await llm.processText(customPrompt, 'custom')
|
||||||
|
} else {
|
||||||
|
logger.info(`Processing with LLM (action: ${action})`)
|
||||||
|
processedText = await llm.processText(transcribedText, action)
|
||||||
|
}
|
||||||
|
|
||||||
if (this._isInTerminalState()) return
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// src/renderer/pages/CommandsPage.tsx
|
// src/renderer/pages/CommandsPage.tsx
|
||||||
// 커스텀 LLM 명령어 관리: CRUD + 프리셋 5개
|
// 커스텀 LLM 명령어 관리: 클릭하면 활성 명령어로 설정 → 다음 녹음 시 적용
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material'
|
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Typography } from '@mui/material'
|
||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||||
import { d3roPalette, d3roFontMono } from '../theme'
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
|
|
||||||
|
|
@ -27,18 +28,39 @@ export function CommandsPage(): React.ReactElement {
|
||||||
const [formName, setFormName] = useState('')
|
const [formName, setFormName] = useState('')
|
||||||
const [formDesc, setFormDesc] = useState('')
|
const [formDesc, setFormDesc] = useState('')
|
||||||
const [formPrompt, setFormPrompt] = useState('')
|
const [formPrompt, setFormPrompt] = useState('')
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await window.electronAPI.instruction.getAll()
|
const [instrResult, configResult] = await Promise.all([
|
||||||
if (result.success) {
|
window.electronAPI.instruction.getAll(),
|
||||||
setInstructions(result.data as CustomInstruction[])
|
window.electronAPI.config.getAll(),
|
||||||
|
])
|
||||||
|
if (instrResult.success) {
|
||||||
|
setInstructions(instrResult.data as CustomInstruction[])
|
||||||
|
}
|
||||||
|
if (configResult.success) {
|
||||||
|
const cfg = configResult.data as Record<string, unknown>
|
||||||
|
setActiveId((cfg['activeInstructionId'] as string) ?? null)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { loadData() }, [loadData])
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
|
const handleActivate = (id: string) => {
|
||||||
|
const newId = activeId === id ? null : id // 토글: 같은 거 누르면 해제
|
||||||
|
setActiveId(newId)
|
||||||
|
// ConfigService에 저장 + defaultLLMAction을 'custom'으로 변경
|
||||||
|
if (newId) {
|
||||||
|
window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@shared/types').AppConfig, value: newId as never })
|
||||||
|
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'custom' })
|
||||||
|
} else {
|
||||||
|
window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@shared/types').AppConfig, value: '' as never })
|
||||||
|
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
setEditId(null)
|
setEditId(null)
|
||||||
setFormName('')
|
setFormName('')
|
||||||
|
|
@ -57,9 +79,7 @@ export function CommandsPage(): React.ReactElement {
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!formName.trim()) return
|
if (!formName.trim()) return
|
||||||
|
|
||||||
if (editId) {
|
if (editId) {
|
||||||
// 편집
|
|
||||||
await window.electronAPI.instruction.update({
|
await window.electronAPI.instruction.update({
|
||||||
id: editId,
|
id: editId,
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
|
|
@ -67,7 +87,6 @@ export function CommandsPage(): React.ReactElement {
|
||||||
prompt: formPrompt.trim(),
|
prompt: formPrompt.trim(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 추가
|
|
||||||
await window.electronAPI.instruction.create({
|
await window.electronAPI.instruction.create({
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
description: formDesc.trim(),
|
description: formDesc.trim(),
|
||||||
|
|
@ -80,12 +99,18 @@ export function CommandsPage(): React.ReactElement {
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
await window.electronAPI.instruction.delete({ id })
|
await window.electronAPI.instruction.delete({ id })
|
||||||
|
if (activeId === id) {
|
||||||
|
setActiveId(null)
|
||||||
|
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' })
|
||||||
|
}
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeInstruction = instructions.find((i) => i.id === activeId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||||||
LLM INSTRUCTIONS — {instructions.length} COMMANDS
|
LLM INSTRUCTIONS — {instructions.length} COMMANDS
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
|
|
@ -94,6 +119,16 @@ export function CommandsPage(): React.ReactElement {
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* 활성 명령어 표시 */}
|
||||||
|
<Box sx={{ mb: 3, p: 1.5, borderRadius: '8px', bgcolor: d3roPalette.bg.inset, boxShadow: 'inset 0 1px 4px rgba(0,0,0,0.3)' }}>
|
||||||
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel, letterSpacing: '1px', mb: 0.5 }}>
|
||||||
|
ACTIVE COMMAND
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '13px', color: activeInstruction ? d3roPalette.accent.amber : d3roPalette.text.disabled }}>
|
||||||
|
{activeInstruction ? `● ${activeInstruction.name}` : '없음 — 명령어를 클릭하여 활성화'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<PhosphorText variant="dim">LOADING...</PhosphorText>
|
<PhosphorText variant="dim">LOADING...</PhosphorText>
|
||||||
) : instructions.length === 0 ? (
|
) : instructions.length === 0 ? (
|
||||||
|
|
@ -104,41 +139,59 @@ export function CommandsPage(): React.ReactElement {
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{instructions.map((inst) => (
|
{instructions.map((inst) => {
|
||||||
<MetalCard key={inst.id}>
|
const isActive = inst.id === activeId
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
<Box
|
||||||
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
|
key={inst.id}
|
||||||
<Box>
|
onClick={() => handleActivate(inst.id)}
|
||||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>
|
sx={{
|
||||||
{inst.name}
|
cursor: 'pointer',
|
||||||
|
borderRadius: '22px',
|
||||||
|
border: isActive ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent',
|
||||||
|
transition: 'border-color 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MetalCard>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
||||||
|
{isActive ? (
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
|
||||||
|
) : (
|
||||||
|
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ fontSize: '14px', fontWeight: 600, color: isActive ? d3roPalette.accent.amber : d3roPalette.text.primary }}>
|
||||||
|
{inst.name}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>
|
||||||
|
{inst.description}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
|
||||||
{inst.description}
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEdit(inst)}
|
||||||
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||||
|
>
|
||||||
|
<EditIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
{!inst.isBuiltin && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDelete(inst.id)}
|
||||||
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
|
||||||
|
>
|
||||||
|
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</MetalCard>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => openEdit(inst)}
|
|
||||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
|
||||||
>
|
|
||||||
<EditIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
{!inst.isBuiltin && (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleDelete(inst.id)}
|
|
||||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
|
|
||||||
>
|
|
||||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</MetalCard>
|
)
|
||||||
))}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -147,39 +200,13 @@ export function CommandsPage(): React.ReactElement {
|
||||||
{editId ? '명령어 편집' : '명령어 추가'}
|
{editId ? '명령어 편집' : '명령어 추가'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField label="이름" value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||||
label="이름"
|
<TextField label="설명" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||||
value={formName}
|
<TextField label="프롬프트 템플릿" value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText="{{text}}는 전사된 텍스트로 치환됩니다" />
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
autoFocus
|
|
||||||
sx={{ mt: 1 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="설명"
|
|
||||||
value={formDesc}
|
|
||||||
onChange={(e) => setFormDesc(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="프롬프트 템플릿"
|
|
||||||
value={formPrompt}
|
|
||||||
onChange={(e) => setFormPrompt(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
helperText="{{text}}는 전사된 텍스트로 치환됩니다"
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>
|
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>취소</Button>
|
||||||
취소
|
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>저장</Button>
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>
|
|
||||||
저장
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue