feat: add customizable AI config with settings panel

- Add config service with JSON persistence in ~/.config/hi-kid/config.json
- Add IPC channels: config:get, config:set, config:changed
- Agent now supports dynamic aiName and systemPrompt with {{AI_NAME}} placeholder
- Extend SettingsPanel with gear icon and config form dropdown
- Replace all hardcoded "Kitten" references with dynamic aiName prop
- LLM parameter changes trigger automatic Agent rebuild
This commit is contained in:
groovyquan committed 2026-04-19 20:59:24 +08:00
1 parent 44179efadd
commit 84b970371c
14 files changed
+1227 -87

No files matched your search

@@ -0,0 +1,93 @@
---
date: 2026-04-19
topic: customizable-ai-config
---
# 可自定义 AI 配置
## Problem Frame
当前 AI 的名称("Kitten")、系统提示词和 LLM 连接参数(base URL、API key、model)全部硬编码在源代码中,用户无法在不修改代码和重新打包的情况下自定义。这会限制:
- 用户无法调整 AI 的角色设定(比如换成一个机器人朋友)
- 用户无法切换到其他 LLM provider
- 产品发布后无法灵活适配不同地区用户的需求
目标:提供一个用户友好的设置面板,让用户可以自定义 AI 名称、系统提示词和 LLM 连接参数,配置持久化到本地文件,修改后即时生效。
## Requirements
**配置项**
- R1. AI 名称:默认值为 `"Kitten"`,用户可自定义
- R2. 系统提示词:默认值为英文对话伙伴提示词(`You are a friendly English conversation partner named Kitten...`,详见代码),用户可自定义
- R3. LLM Base URL:默认值为 `http://localhost:11434/v1`,用户可自定义
- R4. LLM API Key:默认值为 `"ollama"`,用户可自定义
- R5. LLM Model Name:默认值为 `"qwen3:0.6b"`,用户可自定义
**配置存储**
- R6. 配置文件统一放在 `~/.config/hi-kid/config.json` 中(遵循现有代码使用的 `os.homedir() + '.config/hi-kid'` 路径模式,Windows 下为 `%APPDATA%\hi-kid\`),格式为 JSON,包含 `version` 字段(当前为 `1`)
- R7. 配置文件以键值对形式存储,方便未来扩展更多配置项
- R8. 启动时读取配置文件,不存在则使用默认值并自动生成;写入时使用原子写入(先写临时文件再 rename)以避免损坏
- R8a. 配置文件创建时设置权限为 0o600(仅所有者可读写),路径解析后需验证在 `~/.config/hi-kid/` 目录内
- R8b. 若配置文件存在但格式错误或损坏,自动回退到默认值并重新生成有效配置文件
**设置面板 UI**
- R9. 在现有顶部设置区域(`SettingsPanel`)中添加配置入口,扩展为支持多 tab/多面板切换
- R10. 配置编辑区提供清晰的表单:AI 名称(短文本输入)、系统提示词(多行文本输入)、Base URL(文本输入)、API Key(文本输入)、Model Name(文本输入)
- R10a. AI 名称最大长度 32 字符,限制字符集(字母、数字、空格、常用标点);Model Name 最大长度 128 字符,不允许控制字符
- R11. API Key 输入框不使用掩码显示
- R12. 保存按钮点击后即时生效:AI 名称和系统提示词在下次对话轮次生效,LLM 参数变更后自动重建 Agent
- R12a. 重建 Agent 前先取消旧 Agent 的事件订阅(`unsubscribe`),确保旧实例可被垃圾回收
- R13. 保存成功后提供视觉反馈(如 Toast 提示)
- R13a. 表单首次加载时显示预填充的默认值(非 placeholder),加载期间有短暂骨架/占位状态
**即时生效**
- R14. AI 名称和系统提示词修改后在下次对话轮次生效,无需重启应用(当前进行中的对话轮次仍使用旧提示词)
- R15. LLM 参数(base URL / API key / model)修改后,自动中断当前对话、重建 Agent 实例并使用新参数建立到 LLM provider 的 HTTP 连接(对话历史不保留)
**UI 文本同步**
- R16. OnboardingScreen、ChatBubbles 的空状态提示、DownloadScreen 等所有硬编码 `"Kitten"` 的 UI 文本必须从配置中读取 AI 名称并动态渲染
## Success Criteria
- 用户打开设置面板可以修改所有 5 个配置项并保存
- 修改 AI 名称后,UI 中的显示和 Agent 的自我介绍都使用新名称
- 修改系统提示词后,Agent 后续对话遵循新设定
- 修改 LLM 参数后,Agent 能成功连接到新的 provider 或模型
- 配置在应用重启后依然保持
- 若配置文件损坏或格式错误,应用自动回退到默认值并重新生成有效配置文件
## Scope Boundaries
- 不包含配置导入/导出功能
- 不包含配置预设模板功能
- 不包含多 profile 切换功能
- 不包含配置密码保护或加密
- 不包含自动检测 provider 可用性的功能
## Key Decisions
- **设置入口扩展现有 SettingsPanel**:与语音模式切换放在同一区域,通过 icon 或 tab 切换面板。这样保持顶部导航区域简洁,同时用户容易发现。
- **API Key 明文显示**:这是本地 Electron 桌面应用,配置仅保存在用户本地机器;掩码显示会增加交互成本,且孩子用户可能在家庭/教室环境使用(存在屏幕共享/肩窥风险)。加密保护明确排除在范围外。
- **所有配置统一放在一个 JSON 文件**:`~/.config/hi-kid/config.json`(遵循现有 `os.homedir() + '.config/hi-kid'` 路径模式,Windows 下等效),包含 `version` 字段以便未来扩展,预留扩展空间。
- **即时生效**:用户无需手动重启服务,保存后自动处理 Agent 重建。虽然实现复杂度略高,但用户体验好得多。系统提示词变更在下次对话轮次生效(非当前进行中轮次),LLM 参数变更重建 Agent 后对话历史不保留。
## Dependencies / Assumptions
- `~/.config/hi-kid/` 目录已经存在(当前用于存放模型文件)
- `createAgent` 函数需要重构以支持运行时传入 systemPrompt 和 AI 名称,且 Agent 实例支持 unsubscribe
- 主进程具备读写用户 home 目录下配置文件的权限
- 配置优先级:config.json > 环境变量 > 硬编码默认值
## Outstanding Questions
### Resolve Before Planning
(无)
### Deferred to Planning
- **R12 - Needs research**: 重建 Agent 时是否需要优雅中断当前 TTS 播放和 pending 请求?当前的 `agent.abort()` 和 `stopSpeaking()` 是否足够?
- **R9 - Technical**: SettingsPanel 的 tab/面板切换交互模式选择:下拉菜单、tab bar 还是点击齿轮展开面板?
## Next Steps
- \> /ce:plan for structured implementation planning
@@ -0,0 +1,328 @@
---
title: "feat: Add customizable AI config with settings panel"
type: feat
status: active
date: 2026-04-19
origin: docs/brainstorms/2026-04-19-hikid-customizable-ai-config-requirements.md
---
# feat: Add customizable AI config with settings panel
## Overview
Add a user-facing settings panel that allows customizing the AI's name, system prompt, and LLM connection parameters (base URL, API key, model name). Configuration is persisted to a JSON file in the existing `~/.config/hi-kid/` directory and exposed to the renderer via IPC. LLM parameter changes trigger an automatic Agent rebuild.
## Problem Frame
Currently the AI name ("Kitten"), system prompt, and all LLM connection parameters are hardcoded in source. Users cannot customize the AI's persona or switch to a different LLM provider without rebuilding the app. This feature makes HiKid adaptable to different user preferences and deployment environments.
(see origin: docs/brainstorms/2026-04-19-hikid-customizable-ai-config-requirements.md)
## Requirements Trace
- R1. AI name customizable (default "Kitten")
- R2. System prompt customizable (default hardcoded prompt)
- R3-R5. LLM base URL, API key, model name customizable
- R6-R8. Config stored in `~/.config/hi-kid/config.json` with atomic writes, version field, 0o600 permissions
- R8b. Corrupted config falls back to defaults
- R9-R13. SettingsPanel UI with form, save button, toast feedback
- R10a. Input validation (AI name <=32 chars, model name <=128 chars)
- R12a. Agent unsubscribe before rebuild
- R14. System prompt changes take effect on next conversation turn
- R15. LLM param changes rebuild Agent (history not preserved)
- R16. UI text dynamically renders configured AI name
## Scope Boundaries
- No config import/export
- No config preset templates
- No multi-profile switching
- No config encryption or password protection
- No automatic provider availability detection
- TTS server config (port, model path) remains env-var only for this iteration
## Context & Research
### Relevant Code and Patterns
- **IPC pattern (4-step):** `src/preload/index.ts` exposes API -> `src/preload/index.d.ts` declares types -> `src/main/ipc/channels.ts` registers handlers -> renderer consumes via `window.api.*`
- **File I/O:** `src/main/ipc/channels.ts` already uses `fs`, `path`, `os` for `~/.config/hi-kid/` directory operations
- **Agent lifecycle:** `src/main/services/agent.ts` exports `createAgent(config)`, `getAgent()`, `resetAgent()`. The agent is a singleton recreated in `startServicesInternal()`
- **Local state persistence:** Renderer uses `localStorage` for simple flags (`MODE_KEY`, `TEXT_ENABLED_KEY`); complex config lives in main-process files fetched via IPC
- **SettingsPanel:** `src/renderer/src/components/SettingsPanel.tsx` is a compact mode-toggle bar (`press-and-hold` vs `vad`). It will be extended with a config entry point
- **CSS Modules:** Components use co-located `.module.css` files
### Institutional Learnings
No `docs/solutions/` directory exists. This is the second feature plan after voice conversation integration.
## Key Technical Decisions
- **Config file path:** Reuse the existing `os.homedir() + '.config/hi-kid'` convention already used for model files. On Windows this resolves to `%APPDATA%\hi-kid\`.
- **Config priority:** `config.json` > environment variables > hardcoded defaults. Env vars serve as dev-time overrides.
- **SettingsPanel interaction:** A gear icon next to the mode toggles opens a dropdown panel below the settings bar (following the existing `TopicSuggestions` dropdown pattern in `App.tsx`). This avoids adding a new screen or modal while keeping the header compact.
- **Agent rebuild on LLM change:** `stopSpeaking()` -> `agent.abort()` -> create new Agent with new config. The TTS queue (`ttsQueue` Promise chain) may outlive the abort; this is accepted as a known edge case for this iteration.
- **System prompt immediate effect:** pi-agent-core reads `systemPrompt` at the start of each conversation turn via `createContextSnapshot()`. Changes therefore take effect on the *next* turn, not the current in-flight turn.
## Open Questions
### Resolved During Planning
- **SettingsPanel interaction mode:** Gear icon + dropdown panel (follows existing `TopicSuggestions` dropdown pattern)
- **Config precedence:** config.json > env vars > defaults
- **Conversation history on LLM rebuild:** Not preserved (clean start)
### Deferred to Implementation
- **Agent unsubscribe mechanism:** The `@mariozechner/pi-agent-core` `Agent` class may or may not expose an `unsubscribe()` method. If not available, the old agent reference is overwritten and garbage collected. Verify at implementation time.
- **TTS queue race during rebuild:** The `ttsQueue` Promise chain in `createTtsStreamFn` may continue executing after `agent.abort()`. If this causes state confusion, add an abort flag check in the queue.
- **Toast component:** The app currently only has error-toast in `App.tsx`. Either reuse that pattern with success styling or create a lightweight toast utility.
## Implementation Units
- [ ] **Unit 1: Config Service (main process)**
**Goal:** Add config file read/write with defaults, validation, and atomic writes.
**Requirements:** R6, R7, R8, R8a, R8b, R10a
**Dependencies:** None
**Files:**
- Create: `src/main/services/config.ts`
**Approach:**
- Define `AppConfig` interface with all configurable fields plus `version: number`
- `loadConfig()` reads `~/.config/hi-kid/config.json`, merges with defaults, validates fields
- `saveConfig()` writes to temp file then renames for atomicity
- `getDefaultConfig()` returns the fallback values
- On read error or invalid JSON, log and return defaults
- On write, ensure directory exists, set file mode to 0o600
**Patterns to follow:**
- Existing `fs`/`path`/`os` usage in `channels.ts` for directory operations
**Test scenarios:**
- Happy path: Read valid config -> returns parsed values
- Edge case: Missing config file -> returns defaults and creates file
- Error path: Corrupted JSON -> returns defaults, logs error, regenerates file
- Edge case: Empty AI name in config -> falls back to "Kitten"
**Verification:**
- `loadConfig()` returns correct defaults when file is missing
- `saveConfig()` creates a readable JSON file at the expected path
- Corrupted config triggers fallback without crashing
---
- [ ] **Unit 2: IPC Channels for Config**
**Goal:** Expose config read/write and change notifications to the renderer.
**Requirements:** R6, R12
**Dependencies:** Unit 1
**Files:**
- Modify: `src/preload/index.ts`
- Modify: `src/preload/index.d.ts`
- Modify: `src/main/ipc/channels.ts`
**Approach:**
- Add `config:get` invoke: returns current config from main process
- Add `config:set` invoke: validates and saves config, then notifies renderer
- Add `config:changed` subscription: main pushes config updates to renderer after save
- Update `preload/index.d.ts` with new method signatures on `Window.api`
**Patterns to follow:**
- Existing invoke/subscription pattern (see `services:start`, `onServiceStatus`)
**Test scenarios:**
- Happy path: Renderer calls `window.api.getConfig()` -> receives config object
- Happy path: Renderer calls `window.api.setConfig(newConfig)` -> config is saved and `config:changed` fires
- Error path: Invalid config (e.g. empty baseUrl) -> rejected with error message
**Verification:**
- DevTools console can call `window.api.getConfig()` and see the config object
- Changing a value via `setConfig` updates the file on disk
---
- [ ] **Unit 3: Agent Refactor for Dynamic Config**
**Goal:** Make `createAgent` accept dynamic system prompt and AI name; support recreation.
**Requirements:** R1, R2, R12a, R14, R15
**Dependencies:** Unit 1
**Files:**
- Modify: `src/main/services/agent.ts`
- Modify: `src/main/ipc/channels.ts`
**Approach:**
- Update `AgentConfig` interface to include `aiName` and `systemPrompt`
- Update `createAgent` to accept `aiName` and inject it into the system prompt template
- Add `recreateAgent()` helper that calls `stopSpeaking()`, aborts current agent, then creates new one
- In `channels.ts`, replace module-level `const` LLM config with a function that reads from config service
**Patterns to follow:**
- Existing `createAgent`/`getAgent`/`resetAgent` singleton pattern
**Technical design:**
The system prompt template currently hardcodes "Kitten". With dynamic names, the prompt should interpolate the configured name. A simple approach is to replace "named Kitten" with `named ${config.aiName}` in the default prompt, or allow the user to write a fully custom prompt where they can reference the name if desired.
**Test scenarios:**
- Happy path: Create agent with custom name -> system prompt contains the custom name
- Happy path: Create agent with custom system prompt -> prompt is used verbatim
- Integration: `recreateAgent()` stops TTS and creates a new agent instance
- Edge case: Recreate while agent is thinking -> old stream is aborted cleanly
**Verification:**
- Agent responds using the configured name in conversation
- Changing LLM params and saving triggers agent recreation
---
- [ ] **Unit 4: SettingsPanel UI Extension**
**Goal:** Add config editing form to the settings panel with save and feedback.
**Requirements:** R9, R10, R10a, R11, R12, R13, R13a
**Dependencies:** Unit 2
**Files:**
- Modify: `src/renderer/src/components/SettingsPanel.tsx`
- Modify: `src/renderer/src/components/SettingsPanel.module.css`
- Create: `src/renderer/src/hooks/useConfig.ts`
**Approach:**
- Add a gear icon button to the right of the mode toggles in `SettingsPanel`
- Clicking the gear opens a dropdown panel below with a form
- Form fields: AI Name (text), System Prompt (textarea), Base URL (text), API Key (text), Model Name (text)
- Use `useConfig` hook to fetch config on mount and manage form state
- Save button calls `window.api.setConfig()` and shows a success toast
- Validation: AI name max 32 chars, model name max 128 chars, base URL must be valid URL format
- Escape key closes the panel; click outside also closes
**Patterns to follow:**
- `TopicSuggestions` dropdown pattern in `App.tsx` (click outside to close)
- Existing `SettingsPanel.module.css` for styling
**Test scenarios:**
- Happy path: Open settings, change AI name, save -> toast appears, name updates
- Edge case: Enter invalid URL -> inline error, save disabled
- Edge case: Enter empty AI name -> falls back to "Kitten" on save
- Error path: Save fails (IPC error) -> error toast displayed
- Integration: Change LLM params -> main process recreates agent, UI shows brief "reconnecting" state
**Verification:**
- Form opens and closes correctly
- All 5 fields can be edited and saved
- Invalid input prevents save with clear feedback
- Success toast appears after save
---
- [ ] **Unit 5: Dynamic AI Name in UI**
**Goal:** Replace all hardcoded "Kitten" references with dynamic config-driven names.
**Requirements:** R16
**Dependencies:** Unit 2, Unit 4
**Files:**
- Modify: `src/renderer/src/components/OnboardingScreen.tsx`
- Modify: `src/renderer/src/components/ChatBubbles.tsx`
- Modify: `src/renderer/src/components/DownloadScreen.tsx`
- Modify: `src/renderer/src/App.tsx`
**Approach:**
- `OnboardingScreen`: Accept `aiName` prop, render `Hi, I am {aiName}!`
- `ChatBubbles`: Accept `aiName` prop, render `Say hello to {aiName}` in empty state
- `DownloadScreen`: Accept `aiName` prop, replace "Kitten is..." messages with `{aiName} is...`
- `App.tsx`: Fetch config on mount, pass `aiName` to child components
**Patterns to follow:**
- Props drilling pattern already used in the component tree
**Test scenarios:**
- Happy path: Change AI name in settings -> onboarding screen shows new name immediately
- Happy path: Chat bubbles empty state shows configured name
- Happy path: Download screen messages use configured name
- Edge case: Very long AI name -> should not break layout (CSS overflow handling)
**Verification:**
- All three screens display the configured AI name
- Name change in settings is reflected without app restart
---
- [ ] **Unit 6: Integration and Agent Rebuild Wiring**
**Goal:** Wire config changes through the full stack: save -> IPC -> agent recreation -> UI update.
**Requirements:** R12, R12a, R15
**Dependencies:** Unit 1, Unit 2, Unit 3, Unit 4, Unit 5
**Files:**
- Modify: `src/main/ipc/channels.ts`
- Modify: `src/renderer/src/App.tsx`
- Modify: `src/renderer/src/hooks/useConversation.ts` (if needed for state reset)
**Approach:**
- In `channels.ts`, when `config:set` is called:
1. Validate and save config via config service
2. If LLM params changed, call `recreateAgent()` with new config
3. Broadcast `config:changed` to renderer
- In `App.tsx`, listen for `config:changed` and update local `aiName` state
- When Agent is recreated, clear conversation messages (history not preserved per R15)
- Show a brief status message during rebuild (e.g. "Reconnecting to AI...")
**Patterns to follow:**
- Existing `sendToRenderer` pattern for main->renderer events
- Existing `services:status` event flow for service state changes
**Test scenarios:**
- Happy path: Save new LLM params -> agent reconnects, conversation clears, can talk to new model
- Happy path: Save only AI name -> no agent rebuild, name updates instantly in UI
- Happy path: Save only system prompt -> next conversation turn uses new prompt
- Error path: Invalid LLM params (unreachable URL) -> error toast, agent stays on old config
- Integration: Change config while agent is speaking -> speaking stops, agent rebuilds
**Verification:**
- Full round-trip: edit config -> save -> file updates -> agent uses new params -> UI reflects changes
- Changing only name does not interrupt conversation
- Changing LLM params clears conversation history
## System-Wide Impact
- **Interaction graph:** Config changes flow: renderer form -> `config:set` IPC -> config service saves file -> optional agent recreate -> `config:changed` broadcast -> renderer updates UI
- **Error propagation:** Config validation errors returned via IPC rejections and displayed as toasts. Agent rebuild failures show fairy-tale error messages via existing `sendFairyTaleError`.
- **State lifecycle risks:** Agent rebuild discards conversation history. The renderer's `messages` state must be cleared to stay consistent.
- **API surface parity:** The preload API gains `getConfig`, `setConfig`, and `onConfigChanged`. These must be kept in sync between `preload/index.ts` and `preload/index.d.ts`.
- **Unchanged invariants:** TTS/ASR server lifecycle, recording pipeline, VAD auto-restart, mode toggle, and localStorage-based flags (`MODE_KEY`, `TEXT_ENABLED_KEY`) are not affected.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| pi-agent-core Agent may not support `unsubscribe()` | Verify at implementation; if absent, rely on reference overwrite and GC |
| TTS queue continues after agent abort during rebuild | Accepted for this iteration; add abort flag if it causes UX issues |
| Invalid LLM config leaves app in broken state | Validate before save; on rebuild failure, keep old agent running and show error |
| Config file corruption on crash during write | Atomic write (temp+rename) mitigates this; fallback to defaults on read error |
## Documentation / Operational Notes
- No CI/CD changes required
- No migration needed (new feature, no existing config to migrate)
- Future config additions should increment the `version` field in config.json
## Sources & References
- **Origin document:** [docs/brainstorms/2026-04-19-hikid-customizable-ai-config-requirements.md](../brainstorms/2026-04-19-hikid-customizable-ai-config-requirements.md)
- Related code: `src/main/services/agent.ts`, `src/main/ipc/channels.ts`, `src/preload/index.ts`, `src/renderer/src/components/SettingsPanel.tsx`
- Related plan: `docs/plans/2026-04-18-001-feat-voice-conversation-integration-plan.md`
+141 -31
View File
@@ -7,10 +7,18 @@ import {
getIsSpeaking,
isAgentBusy,
resetConversationState,
resetAgent,
type AgentConfig
} from '../services/agent'
import { recordWithVad, stopRecordingProcess, convertAudio, analyzeAudio, MIN_VALID_RMS } from '../services/recorder'
import {
recordWithVad,
stopRecordingProcess,
convertAudio,
analyzeAudio,
MIN_VALID_RMS
} from '../services/recorder'
import { checkModelsExist, downloadModels, type DownloadConfig } from '../services/download'
import { loadConfig, saveConfig, type AppConfig } from '../services/config'
import fs from 'fs'
import path from 'path'
import os from 'os'
@@ -20,7 +28,7 @@ const HI_KID_DIR = path.join(process.env.HOME || os.homedir(), '.config', 'hi-ki
const MODELS_BASE_DIR = path.join(HI_KID_DIR, 'models')
const BIN_DIR = path.join(HI_KID_DIR, 'bin')
const config: ServerConfig = {
const serverConfig: ServerConfig = {
ttsPort: parseInt(process.env.TTS_PORT || '8081'),
asrPort: parseInt(process.env.ASR_PORT || '8082'),
ttsModelPath:
@@ -29,11 +37,23 @@ const config: ServerConfig = {
process.env.ASR_MODEL_PATH || path.join(MODELS_BASE_DIR, 'qwen3_asr_rs', 'Qwen3-ASR-0.6B')
}
const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL || 'http://localhost:11434/v1'
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || 'ollama'
const MODEL_NAME = process.env.MODEL_NAME || 'qwen3:0.6b'
function getLlmConfig(): Pick<
AppConfig,
'baseUrl' | 'apiKey' | 'modelName' | 'aiName' | 'systemPrompt'
> {
const cfg = loadConfig()
return {
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
modelName: cfg.modelName,
aiName: cfg.aiName,
systemPrompt: cfg.systemPrompt
}
}
const LLM_TIMEOUT_MS = parseInt(process.env.LLM_TIMEOUT_MS || '15000', 10)
const LLM_UNREACHABLE_HINT = process.env.LLM_UNREACHABLE_HINT || 'Ollama seems to be taking a nap. Make sure it is running!'
const LLM_UNREACHABLE_HINT =
process.env.LLM_UNREACHABLE_HINT || 'Ollama seems to be taking a nap. Make sure it is running!'
import { type Agent } from '@mariozechner/pi-agent-core'
@@ -43,18 +63,16 @@ async function promptWithTimeout(agent: Agent, text: string): Promise<void> {
reject(new Error('LLM_TIMEOUT'))
}, LLM_TIMEOUT_MS)
agent
.prompt(text)
.then(
() => {
clearTimeout(timer)
resolve()
},
(err: unknown) => {
clearTimeout(timer)
reject(err)
}
)
agent.prompt(text).then(
() => {
clearTimeout(timer)
resolve()
},
(err: unknown) => {
clearTimeout(timer)
reject(err)
}
)
})
}
@@ -72,8 +90,8 @@ function getDownloadConfig(): DownloadConfig {
const ttsBinUrl = process.env.TTS_BIN_DOWNLOAD_URL || ''
const asrBinUrl = process.env.ASR_BIN_DOWNLOAD_URL || ''
const ttsLocal = path.resolve(config.ttsModelPath)
const asrLocal = path.resolve(config.asrModelPath)
const ttsLocal = path.resolve(serverConfig.ttsModelPath)
const asrLocal = path.resolve(serverConfig.asrModelPath)
const ttsBinLocal = path.join(BIN_DIR, 'kitten-tts-server')
const asrBinLocal = path.join(BIN_DIR, 'asr-server')
@@ -192,27 +210,30 @@ async function startServicesInternal(): Promise<void> {
try {
// Check model directories exist before starting servers
if (!fs.existsSync(config.ttsModelPath)) {
if (!fs.existsSync(serverConfig.ttsModelPath)) {
throw new Error(
`TTS model not found at ${config.ttsModelPath}. Place the model there or set TTS_MODEL_DOWNLOAD_URL to download it.`
`TTS model not found at ${serverConfig.ttsModelPath}. Place the model there or set TTS_MODEL_DOWNLOAD_URL to download it.`
)
}
if (!fs.existsSync(config.asrModelPath)) {
if (!fs.existsSync(serverConfig.asrModelPath)) {
throw new Error(
`ASR model not found at ${config.asrModelPath}. Place the model there or set ASR_MODEL_DOWNLOAD_URL to download it.`
`ASR model not found at ${serverConfig.asrModelPath}. Place the model there or set ASR_MODEL_DOWNLOAD_URL to download it.`
)
}
fs.mkdirSync(TMP_DIR, { recursive: true })
await startServers(config)
await startServers(serverConfig)
servicesReady = true
sendToRenderer('service:status', { ready: true })
const llmCfg = getLlmConfig()
const agentConfig: AgentConfig = {
baseUrl: OPENAI_BASE_URL,
apiKey: OPENAI_API_KEY,
modelName: MODEL_NAME,
ttsPort: config.ttsPort,
baseUrl: llmCfg.baseUrl,
apiKey: llmCfg.apiKey,
modelName: llmCfg.modelName,
aiName: llmCfg.aiName,
systemPrompt: llmCfg.systemPrompt,
ttsPort: serverConfig.ttsPort,
unreachableHint: LLM_UNREACHABLE_HINT
}
await createAgent(agentConfig)
@@ -224,6 +245,27 @@ async function startServicesInternal(): Promise<void> {
}
}
async function recreateAgent(): Promise<void> {
stopSpeaking()
const agent = getAgent()
if (agent) {
agent.abort()
resetAgent()
}
const llmCfg = getLlmConfig()
const agentConfig: AgentConfig = {
baseUrl: llmCfg.baseUrl,
apiKey: llmCfg.apiKey,
modelName: llmCfg.modelName,
aiName: llmCfg.aiName,
systemPrompt: llmCfg.systemPrompt,
ttsPort: serverConfig.ttsPort,
unreachableHint: LLM_UNREACHABLE_HINT
}
await createAgent(agentConfig)
}
export function registerIpcChannels(): void {
ipcMain.handle('services:start', async () => {
await startServicesInternal()
@@ -321,7 +363,7 @@ export function registerIpcChannels(): void {
if (recorderLock || !servicesReady) return false
recorderLock = (async () => {
sendToRenderer('kitten:state', 'listening')
sendToRenderer('kitten:state', 'listening')
try {
await recordWithVad(RECORDING_RAW)
@@ -347,7 +389,7 @@ export function registerIpcChannels(): void {
formData.append('language', 'english')
formData.append('response_format', 'json')
const res = await fetch(`${asrUrl(config)}/v1/audio/transcriptions`, {
const res = await fetch(`${asrUrl(serverConfig)}/v1/audio/transcriptions`, {
method: 'POST',
body: formData
})
@@ -404,4 +446,72 @@ export function registerIpcChannels(): void {
resetRecordingState()
}
})
// --- Config handlers ---
ipcMain.handle('config:get', () => {
const cfg = loadConfig()
return {
aiName: cfg.aiName,
systemPrompt: cfg.systemPrompt,
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
modelName: cfg.modelName
}
})
ipcMain.handle('config:set', async (_event, newConfig: unknown) => {
if (typeof newConfig !== 'object' || newConfig === null) {
throw new Error('Invalid config object')
}
const n = newConfig as Record<string, unknown>
// Validate required fields
if (typeof n.aiName !== 'string' || n.aiName.length > 32) {
throw new Error('AI name must be a string with max 32 characters')
}
if (typeof n.systemPrompt !== 'string') {
throw new Error('System prompt must be a string')
}
if (typeof n.baseUrl !== 'string' || n.baseUrl.length > 512) {
throw new Error('Base URL must be a string with max 512 characters')
}
if (typeof n.apiKey !== 'string' || n.apiKey.length > 512) {
throw new Error('API key must be a string with max 512 characters')
}
if (typeof n.modelName !== 'string' || n.modelName.length > 128) {
throw new Error('Model name must be a string with max 128 characters')
}
const current = loadConfig()
const updated: AppConfig = {
version: current.version,
aiName: n.aiName.trim() || current.aiName,
systemPrompt: n.systemPrompt || current.systemPrompt,
baseUrl: n.baseUrl.trim() || current.baseUrl,
apiKey: n.apiKey,
modelName: n.modelName.trim() || current.modelName
}
const llmChanged =
updated.baseUrl !== current.baseUrl ||
updated.apiKey !== current.apiKey ||
updated.modelName !== current.modelName
saveConfig(updated)
if (llmChanged && servicesReady) {
try {
await recreateAgent()
resetConversationState()
sendToRenderer('config:changed', updated)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('[config:set] Failed to recreate agent:', message)
sendToRenderer('error', { message: LLM_UNREACHABLE_HINT })
throw new Error('Failed to reconnect to AI with new settings')
}
} else {
sendToRenderer('config:changed', updated)
}
})
}
+12 -4
View File
@@ -140,6 +140,8 @@ export interface AgentConfig {
modelName: string
ttsPort: number
unreachableHint?: string
aiName?: string
systemPrompt?: string
}
export async function createAgent(config: AgentConfig): Promise<Agent> {
@@ -185,9 +187,10 @@ export async function createAgent(config: AgentConfig): Promise<Agent> {
}
}
const agent = new Agent({
initialState: {
systemPrompt: `You are a friendly English conversation partner named Kitten. Your goal is to help the user practice spoken English.
const aiName = config.aiName?.trim() || 'Kitten'
const rawSystemPrompt =
config.systemPrompt?.trim() ||
`You are a friendly English conversation partner named ${aiName}. Your goal is to help the user practice spoken English.
Rules:
- Always respond in English
@@ -197,7 +200,12 @@ Rules:
- Ask follow-up questions to keep the conversation going
- Adapt to the user's level - if they're beginner, use simpler vocabulary
- Absolute prohibition of emoji usage / Emojis are strictly forbidden
`,
`
const systemPrompt = rawSystemPrompt.replace(/\{\{AI_NAME\}\}/g, aiName)
const agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel: 'off',
tools: []
+107
View File
@@ -0,0 +1,107 @@
import fs from 'fs'
import path from 'path'
import os from 'os'
const CONFIG_DIR = path.join(process.env.HOME || os.homedir(), '.config', 'hi-kid')
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
const DEFAULT_SYSTEM_PROMPT = `You are a friendly English conversation partner named {{AI_NAME}}. Your goal is to help the user practice spoken English.
Rules:
- Always respond in English
- Keep responses concise (1-3 sentences) for natural conversation flow
- Be encouraging and supportive
- If the user makes grammar mistakes, gently correct them
- Ask follow-up questions to keep the conversation going
- Adapt to the user's level - if they're beginner, use simpler vocabulary
- Absolute prohibition of emoji usage / Emojis are strictly forbidden
`
export interface AppConfig {
version: number
aiName: string
systemPrompt: string
baseUrl: string
apiKey: string
modelName: string
}
function getDefaultConfig(): AppConfig {
return {
version: 1,
aiName: 'Kitten',
systemPrompt: DEFAULT_SYSTEM_PROMPT,
baseUrl: 'http://localhost:11434/v1',
apiKey: 'ollama',
modelName: 'qwen3:0.6b'
}
}
function validateConfig(config: unknown): config is AppConfig {
if (typeof config !== 'object' || config === null) return false
const c = config as Record<string, unknown>
if (typeof c.aiName !== 'string' || c.aiName.length > 32) return false
if (typeof c.systemPrompt !== 'string') return false
if (typeof c.baseUrl !== 'string' || c.baseUrl.length > 512) return false
if (typeof c.apiKey !== 'string' || c.apiKey.length > 512) return false
if (typeof c.modelName !== 'string' || c.modelName.length > 128) return false
return true
}
function mergeWithDefaults(partial: unknown): AppConfig {
const defaults = getDefaultConfig()
if (typeof partial !== 'object' || partial === null) return defaults
const p = partial as Record<string, unknown>
return {
version: typeof p.version === 'number' ? p.version : defaults.version,
aiName: typeof p.aiName === 'string' && p.aiName.trim() ? p.aiName.trim() : defaults.aiName,
systemPrompt:
typeof p.systemPrompt === 'string' && p.systemPrompt.trim()
? p.systemPrompt
: defaults.systemPrompt,
baseUrl:
typeof p.baseUrl === 'string' && p.baseUrl.trim() ? p.baseUrl.trim() : defaults.baseUrl,
apiKey: typeof p.apiKey === 'string' ? p.apiKey : defaults.apiKey,
modelName:
typeof p.modelName === 'string' && p.modelName.trim()
? p.modelName.trim()
: defaults.modelName
}
}
export function loadConfig(): AppConfig {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf-8')
const parsed = JSON.parse(data) as unknown
const merged = mergeWithDefaults(parsed)
if (!validateConfig(merged)) {
console.warn('[Config] Invalid config structure, using defaults')
return getDefaultConfig()
}
return merged
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.warn('[Config] Failed to load config, using defaults:', message)
}
return getDefaultConfig()
}
export function saveConfig(config: AppConfig): void {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true })
const tempFile = `${CONFIG_FILE}.tmp`
const data = JSON.stringify(config, null, 2)
fs.writeFileSync(tempFile, data, { mode: 0o600 })
fs.renameSync(tempFile, CONFIG_FILE)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('[Config] Failed to save config:', message)
throw err
}
}
export function getConfig(): AppConfig {
return loadConfig()
}
+11
View File
@@ -2,6 +2,14 @@ import { ElectronAPI } from '@electron-toolkit/preload'
type KittenState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'interrupted'
interface ConfigData {
aiName: string
systemPrompt: string
baseUrl: string
apiKey: string
modelName: string
}
interface Api {
// Invokes
startServices(): Promise<void>
@@ -13,6 +21,8 @@ interface Api {
startDownload(): Promise<void>
startRecording(): Promise<boolean>
stopRecording(): Promise<void>
getConfig(): Promise<ConfigData>
setConfig(config: ConfigData): Promise<void>
// Subscriptions
onServiceStatus(callback: (status: { ready: boolean }) => void): () => void
@@ -24,6 +34,7 @@ interface Api {
callback: (data: { bytes: number; total: number; currentFile: string }) => void
): () => void
onError(callback: (data: { message: string }) => void): () => void
onConfigChanged(callback: (config: ConfigData) => void): () => void
}
declare global {
+39
View File
@@ -12,6 +12,20 @@ const api = {
startDownload: (): Promise<void> => ipcRenderer.invoke('models:download'),
startRecording: (): Promise<boolean> => ipcRenderer.invoke('recorder:start'),
stopRecording: (): Promise<void> => ipcRenderer.invoke('recorder:stop'),
getConfig: (): Promise<{
aiName: string
systemPrompt: string
baseUrl: string
apiKey: string
modelName: string
}> => ipcRenderer.invoke('config:get'),
setConfig: (config: {
aiName: string
systemPrompt: string
baseUrl: string
apiKey: string
modelName: string
}): Promise<void> => ipcRenderer.invoke('config:set', config),
// -- Subscriptions (main -> renderer) --
onServiceStatus: (callback: (status: { ready: boolean }) => void): (() => void) => {
@@ -78,6 +92,31 @@ const api = {
return (): void => {
ipcRenderer.removeListener('error', handler)
}
},
onConfigChanged: (
callback: (config: {
aiName: string
systemPrompt: string
baseUrl: string
apiKey: string
modelName: string
}) => void
): (() => void) => {
const handler = (
_: unknown,
config: {
aiName: string
systemPrompt: string
baseUrl: string
apiKey: string
modelName: string
}
): void => callback(config)
ipcRenderer.on('config:changed', handler)
return (): void => {
ipcRenderer.removeListener('config:changed', handler)
}
}
}
+25 -7
View File
@@ -8,6 +8,7 @@ import DownloadScreen from '@renderer/components/DownloadScreen'
import OnboardingScreen from '@renderer/components/OnboardingScreen'
import TopicSuggestions from '@renderer/components/TopicSuggestions'
import { useConversation } from '@renderer/hooks/useConversation'
import { useConfig } from '@renderer/hooks/useConfig'
type Screen = 'loading' | 'download' | 'onboarding' | 'conversation'
@@ -81,6 +82,9 @@ function App(): React.JSX.Element {
clearMessages
} = useConversation()
const { config: appConfig } = useConfig()
const aiName = appConfig?.aiName ?? 'Kitten'
// Close dropdown when clicking outside
useEffect(() => {
if (!topicDropdownOpen) return
@@ -192,17 +196,28 @@ function App(): React.JSX.Element {
aria-label="Topic suggestions"
title="Topic suggestions"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
<span>Ideas</span>
</button>
{topicDropdownOpen && (
<div className="topic-dropdown-panel">
<TopicSuggestions onTopicClick={(topic) => {
handleTopicClick(topic)
setTopicDropdownOpen(false)
}} />
<TopicSuggestions
onTopicClick={(topic) => {
handleTopicClick(topic)
setTopicDropdownOpen(false)
}}
/>
</div>
)}
</div>
@@ -322,10 +337,13 @@ function App(): React.JSX.Element {
bytes={downloadProgress.bytes}
total={downloadProgress.total}
currentFile={downloadProgress.currentFile}
aiName={aiName}
/>
)}
{screen === 'onboarding' && <OnboardingScreen onStart={handleCompleteOnboarding} />}
{screen === 'onboarding' && (
<OnboardingScreen onStart={handleCompleteOnboarding} aiName={aiName} />
)}
</main>
<footer className="status-bar">
@@ -344,7 +362,7 @@ function App(): React.JSX.Element {
{showSidebar && (
<aside className="chat-sidebar">
<ChatBubbles messages={messages} visible />
<ChatBubbles messages={messages} visible aiName={aiName} />
</aside>
)}
</div>
+4 -2
View File
@@ -4,6 +4,7 @@ import { type ChatMessage } from '@renderer/types/conversation'
interface ChatBubblesProps {
messages: ChatMessage[]
visible: boolean
aiName?: string
}
function TypingDots(): React.JSX.Element {
@@ -35,7 +36,8 @@ function AssistantIcon(): React.JSX.Element {
export default function ChatBubbles({
messages,
visible
visible,
aiName = 'Kitten'
}: ChatBubblesProps): React.JSX.Element | null {
const endRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
@@ -66,7 +68,7 @@ export default function ChatBubbles({
{messages.length === 0 && (
<div className="chat-empty-hint">
<span>Say hello to Kitten</span>
<span>Say hello to {aiName}</span>
</div>
)}
@@ -5,12 +5,14 @@ interface DownloadScreenProps {
bytes: number
total: number
currentFile: string
aiName?: string
}
export default function DownloadScreen({
bytes,
total,
currentFile
currentFile,
aiName = 'Kitten'
}: DownloadScreenProps): React.JSX.Element {
const [dots, setDots] = useState('')
@@ -25,9 +27,9 @@ export default function DownloadScreen({
const isComplete = percent >= 100
const messages = [
'Kitten is getting ready',
'Kitten is unpacking toys',
'Kitten is brushing fur',
`${aiName} is getting ready`,
`${aiName} is unpacking toys`,
`${aiName} is brushing fur`,
'Almost there'
]
const messageIndex = Math.min(Math.floor((percent / 100) * messages.length), messages.length - 1)
@@ -3,9 +3,10 @@ import styles from './OnboardingScreen.module.css'
interface OnboardingScreenProps {
onStart: () => void
aiName?: string
}
export default function OnboardingScreen({ onStart }: OnboardingScreenProps): React.JSX.Element {
export default function OnboardingScreen({ onStart, aiName = 'Kitten' }: OnboardingScreenProps): React.JSX.Element {
return (
<div className={styles.container}>
<div className={styles.kittenWrapper}>
@@ -13,7 +14,7 @@ export default function OnboardingScreen({ onStart }: OnboardingScreenProps): Re
</div>
<div className={styles.content}>
<h2 className={styles.title}>Hi, I am Kitten!</h2>
<h2 className={styles.title}>Hi, I am {aiName}!</h2>
<p className={styles.subtitle}>Your English-speaking friend</p>
<div className={styles.steps}>
@@ -1,3 +1,7 @@
.wrapper {
position: relative;
}
.panel {
display: flex;
align-items: center;
@@ -37,10 +41,19 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.divider {
width: 1px;
height: 18px;
background: rgba(107, 76, 59, 0.2);
margin: 0 2px;
}
.label {
font-size: 12px;
font-weight: 700;
line-height: 1.2;
font-size: 11px;
font-weight: 600;
color: var(--ei-c-brown);
margin-bottom: 4px;
display: block;
}
.sub {
@@ -48,3 +61,130 @@
font-weight: 500;
opacity: 0.8;
}
.dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 320px;
max-width: calc(100vw - 32px);
padding: 14px;
border-radius: 14px;
border: 2px solid rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.field {
margin-bottom: 10px;
}
.field:last-of-type {
margin-bottom: 0;
}
.input {
width: 100%;
padding: 6px 10px;
border-radius: 8px;
border: 1.5px solid rgba(107, 76, 59, 0.2);
background: rgba(255, 255, 255, 0.8);
color: var(--ei-c-brown);
font-family: inherit;
font-size: 12px;
outline: none;
transition: border-color 0.2s ease;
box-sizing: border-box;
}
.input:focus {
border-color: var(--ei-c-mint-dark);
}
.textarea {
resize: vertical;
min-height: 60px;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.saveBtn {
padding: 6px 16px;
border-radius: 10px;
border: 2px solid transparent;
background: var(--ei-c-mint);
color: var(--ei-c-brown);
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: url('/cursor-icon.png') 4 0, pointer;
transition: all 0.2s ease;
}
.saveBtn:hover:not(:disabled) {
background: var(--ei-c-mint-dark);
}
.saveBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.errorText {
font-size: 11px;
color: #d93025;
margin: 4px 0 0;
}
.toast {
position: absolute;
top: calc(100% + 8px);
right: 0;
padding: 8px 16px;
border-radius: 10px;
background: var(--ei-c-mint);
color: var(--ei-c-brown);
font-size: 12px;
font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
animation: toastIn 0.2s ease;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.skeleton {
display: flex;
flex-direction: column;
gap: 10px;
}
.skeletonRow {
height: 28px;
border-radius: 8px;
background: rgba(107, 76, 59, 0.08);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
+263 -34
View File
@@ -1,3 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useConfig } from '@renderer/hooks/useConfig'
import styles from './SettingsPanel.module.css'
interface SettingsPanelProps {
@@ -9,41 +11,268 @@ export default function SettingsPanel({
mode,
onModeChange
}: SettingsPanelProps): React.JSX.Element {
const [panelOpen, setPanelOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [showToast, setShowToast] = useState(false)
const { config, loading } = useConfig()
const panelRef = useRef<HTMLDivElement>(null)
const [form, setForm] = useState({
aiName: '',
systemPrompt: '',
baseUrl: '',
apiKey: '',
modelName: ''
})
// Close panel on click outside or escape
useEffect(() => {
if (!panelOpen) return
const handleClick = (e: MouseEvent): void => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setPanelOpen(false)
}
}
const handleKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
setPanelOpen(false)
}
}
document.addEventListener('pointerdown', handleClick)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('pointerdown', handleClick)
document.removeEventListener('keydown', handleKey)
}
}, [panelOpen])
// Toast auto-dismiss
useEffect(() => {
if (!showToast) return
const timer = setTimeout(() => setShowToast(false), 2500)
return () => clearTimeout(timer)
}, [showToast])
const updateField = useCallback((field: keyof typeof form, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }))
setSaveError(null)
}, [])
const isValid =
form.aiName.length <= 32 &&
form.modelName.length <= 128 &&
form.baseUrl.length > 0 &&
form.baseUrl.length <= 512
const handleSave = async (): Promise<void> => {
if (!isValid) return
setSaving(true)
setSaveError(null)
try {
await window.api.setConfig({
aiName: form.aiName.trim() || 'Kitten',
systemPrompt: form.systemPrompt,
baseUrl: form.baseUrl.trim(),
apiKey: form.apiKey,
modelName: form.modelName.trim()
})
setShowToast(true)
setPanelOpen(false)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setSaveError(message)
} finally {
setSaving(false)
}
}
return (
<div className={styles.panel}>
<button
className={`${styles.modeButton} ${mode === 'press-and-hold' ? styles.active : ''}`}
onClick={() => onModeChange('press-and-hold')}
type="button"
aria-pressed={mode === 'press-and-hold'}
title="Press & Hold"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0" />
<path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2" />
<path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8" />
<path d="M18 8a2 2 0 0 1 2 2v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
<span className={styles.sub}>Hold</span>
</button>
<button
className={`${styles.modeButton} ${mode === 'vad' ? styles.active : ''}`}
onClick={() => onModeChange('vad')}
type="button"
aria-pressed={mode === 'vad'}
title="Auto Listen"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 10v3" />
<path d="M6 6v11" />
<path d="M10 3v18" />
<path d="M14 8v7" />
<path d="M18 5v13" />
<path d="M22 10v3" />
</svg>
<span className={styles.sub}>Auto</span>
</button>
<div className={styles.wrapper} ref={panelRef}>
<div className={styles.panel}>
<button
className={`${styles.modeButton} ${mode === 'press-and-hold' ? styles.active : ''}`}
onClick={() => onModeChange('press-and-hold')}
type="button"
aria-pressed={mode === 'press-and-hold'}
title="Press & Hold"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0" />
<path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2" />
<path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8" />
<path d="M18 8a2 2 0 0 1 2 2v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
<span className={styles.sub}>Hold</span>
</button>
<button
className={`${styles.modeButton} ${mode === 'vad' ? styles.active : ''}`}
onClick={() => onModeChange('vad')}
type="button"
aria-pressed={mode === 'vad'}
title="Auto Listen"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 10v3" />
<path d="M6 6v11" />
<path d="M10 3v18" />
<path d="M14 8v7" />
<path d="M18 5v13" />
<path d="M22 10v3" />
</svg>
<span className={styles.sub}>Auto</span>
</button>
<div className={styles.divider} />
<button
className={`${styles.modeButton} ${panelOpen ? styles.active : ''}`}
onClick={() => {
if (!panelOpen && config) {
setForm({
aiName: config.aiName,
systemPrompt: config.systemPrompt,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
modelName: config.modelName
})
}
setPanelOpen((prev) => !prev)
}}
type="button"
aria-pressed={panelOpen}
title="Settings"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
</div>
{panelOpen && (
<div className={styles.dropdown}>
{loading ? (
<div className={styles.skeleton}>
<div className={styles.skeletonRow} />
<div className={styles.skeletonRow} />
<div className={styles.skeletonRow} />
</div>
) : (
<>
<div className={styles.field}>
<label className={styles.label} htmlFor="cfg-aiName">
AI Name
</label>
<input
id="cfg-aiName"
className={styles.input}
type="text"
value={form.aiName}
onChange={(e) => updateField('aiName', e.target.value)}
maxLength={32}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="cfg-systemPrompt">
System Prompt
</label>
<textarea
id="cfg-systemPrompt"
className={`${styles.input} ${styles.textarea}`}
value={form.systemPrompt}
onChange={(e) => updateField('systemPrompt', e.target.value)}
rows={4}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="cfg-baseUrl">
Base URL
</label>
<input
id="cfg-baseUrl"
className={styles.input}
type="text"
value={form.baseUrl}
onChange={(e) => updateField('baseUrl', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="cfg-apiKey">
API Key
</label>
<input
id="cfg-apiKey"
className={styles.input}
type="text"
value={form.apiKey}
onChange={(e) => updateField('apiKey', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="cfg-modelName">
Model Name
</label>
<input
id="cfg-modelName"
className={styles.input}
type="text"
value={form.modelName}
onChange={(e) => updateField('modelName', e.target.value)}
maxLength={128}
/>
</div>
{saveError && <p className={styles.errorText}>{saveError}</p>}
<div className={styles.actions}>
<button
className={styles.saveBtn}
onClick={handleSave}
disabled={!isValid || saving}
type="button"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</>
)}
</div>
)}
{showToast && <div className={styles.toast}>Settings saved!</div>}
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react'
export interface ConfigData {
aiName: string
systemPrompt: string
baseUrl: string
apiKey: string
modelName: string
}
interface UseConfigReturn {
config: ConfigData | null
loading: boolean
error: string | null
}
export function useConfig(): UseConfigReturn {
const [config, setConfig] = useState<ConfigData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
window.api
.getConfig()
.then((data) => {
if (!cancelled) {
setConfig(data)
setLoading(false)
}
})
.catch((err: unknown) => {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
setLoading(false)
}
})
const unsubscribe = window.api.onConfigChanged((newConfig) => {
setConfig(newConfig)
})
return () => {
cancelled = true
unsubscribe()
}
}, [])
return { config, loading, error }
}