From 84b970371c334cec013030218e4aeccf6d27755d Mon Sep 17 00:00:00 2001 From: groovyquan Date: Sun, 19 Apr 2026 20:58:52 +0800 Subject: [PATCH] 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 --- ...kid-customizable-ai-config-requirements.md | 93 +++++ ...19-002-feat-customizable-ai-config-plan.md | 328 ++++++++++++++++++ src/main/ipc/channels.ts | 172 +++++++-- src/main/services/agent.ts | 16 +- src/main/services/config.ts | 107 ++++++ src/preload/index.d.ts | 11 + src/preload/index.ts | 39 +++ src/renderer/src/App.tsx | 32 +- src/renderer/src/components/ChatBubbles.tsx | 6 +- .../src/components/DownloadScreen.tsx | 10 +- .../src/components/OnboardingScreen.tsx | 5 +- .../src/components/SettingsPanel.module.css | 146 +++++++- src/renderer/src/components/SettingsPanel.tsx | 297 ++++++++++++++-- src/renderer/src/hooks/useConfig.ts | 52 +++ 14 files changed, 1227 insertions(+), 87 deletions(-) create mode 100644 docs/brainstorms/2026-04-19-hikid-customizable-ai-config-requirements.md create mode 100644 docs/plans/2026-04-19-002-feat-customizable-ai-config-plan.md create mode 100644 src/main/services/config.ts create mode 100644 src/renderer/src/hooks/useConfig.ts diff --git a/docs/brainstorms/2026-04-19-hikid-customizable-ai-config-requirements.md b/docs/brainstorms/2026-04-19-hikid-customizable-ai-config-requirements.md new file mode 100644 index 0000000..cad668a --- /dev/null +++ b/docs/brainstorms/2026-04-19-hikid-customizable-ai-config-requirements.md @@ -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 diff --git a/docs/plans/2026-04-19-002-feat-customizable-ai-config-plan.md b/docs/plans/2026-04-19-002-feat-customizable-ai-config-plan.md new file mode 100644 index 0000000..7926a12 --- /dev/null +++ b/docs/plans/2026-04-19-002-feat-customizable-ai-config-plan.md @@ -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` diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 49ef09b..6280580 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -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 { 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 { 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 { } } +async function recreateAgent(): Promise { + 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 + + // 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) + } + }) } diff --git a/src/main/services/agent.ts b/src/main/services/agent.ts index b7b3c6a..70df0b7 100644 --- a/src/main/services/agent.ts +++ b/src/main/services/agent.ts @@ -140,6 +140,8 @@ export interface AgentConfig { modelName: string ttsPort: number unreachableHint?: string + aiName?: string + systemPrompt?: string } export async function createAgent(config: AgentConfig): Promise { @@ -185,9 +187,10 @@ export async function createAgent(config: AgentConfig): Promise { } } - 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: [] diff --git a/src/main/services/config.ts b/src/main/services/config.ts new file mode 100644 index 0000000..564e2cb --- /dev/null +++ b/src/main/services/config.ts @@ -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 + 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 + 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() +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 7cc712e..1d1c571 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 @@ -13,6 +21,8 @@ interface Api { startDownload(): Promise startRecording(): Promise stopRecording(): Promise + getConfig(): Promise + setConfig(config: ConfigData): Promise // 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 { diff --git a/src/preload/index.ts b/src/preload/index.ts index ddc8536..65ea320 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,20 @@ const api = { startDownload: (): Promise => ipcRenderer.invoke('models:download'), startRecording: (): Promise => ipcRenderer.invoke('recorder:start'), stopRecording: (): Promise => 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 => 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) + } } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f4d3875..dc7f4bd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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" > - + Ideas {topicDropdownOpen && (
- { - handleTopicClick(topic) - setTopicDropdownOpen(false) - }} /> + { + handleTopicClick(topic) + setTopicDropdownOpen(false) + }} + />
)} @@ -322,10 +337,13 @@ function App(): React.JSX.Element { bytes={downloadProgress.bytes} total={downloadProgress.total} currentFile={downloadProgress.currentFile} + aiName={aiName} /> )} - {screen === 'onboarding' && } + {screen === 'onboarding' && ( + + )}