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:
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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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: []
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Vendored
+11
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user