diff --git a/Agent.md b/Agent.md index 9779b666..57a667aa 100644 --- a/Agent.md +++ b/Agent.md @@ -86,7 +86,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - Unit tests `npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t); Batch 1 remainder: i18n.js full dictionary (373 keys zh/en parity, auto-generated `lib/i18n-dicts.ts` + detectLocale/getLocale/setLocale/t) + markdown.js → `lib/markdown.ts` (marked/DOMPurify/hljs injectable, fence-closed heuristic + block-projection streaming); Batch 2: chat.js → `lib/transcript.ts` (pure per-session state machine — delta/done/toolStart/toolEnd/merge-group/clearTyping, sid buckets P3, 15 tests) + `components/TranscriptView.tsx` (React chat area: streaming plain-text → markdown-on-done via injectable renderer, tool rows running→done/failed with elapsed + expandable output, merge-group bar summary + collapse/user-expanded, remote label, 7 tests); Batch 3: Sidebar/ResultPanel/WorkspaceView/FileTree components; Batch 4: dialog components (help/memory/skills/rename/confirm/rewind/open-session/new-session/welcome/github-device) + `lib/dialog` reducer + dialogLists/rewind/openSession pure logic; Batch 5: `lib/daemonBridge.ts` event bridge (multi-subscriber onEvent → transcript/app stores, sid-routed #977, queue-injection #655) + `DaemonBridgeProvider` (mount-once/dispose, tRef) + Shell chat-loop wiring (live daemon data: transcript/composer/sidebar/result-panel, #1016-#1018) + `DialogHost` (command routing → dialogs: /help /memory /skills /rewind /rename /delete /sessions /open /resume + direct /clear /compact /version /image; sidebar new-chat/open-chat buttons, #1019) + workspace view switching (Sidebar `#side-nav` rail: sessions/projects/tasks/rants/settings with active highlight; Shell `activeView` state per vanilla `switchView` toggle semantics — panel hides chat chrome, sessions restores it; #1020) + - React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t); Batch 1 remainder: i18n.js full dictionary (373 keys zh/en parity, auto-generated `lib/i18n-dicts.ts` + detectLocale/getLocale/setLocale/t) + markdown.js → `lib/markdown.ts` (marked/DOMPurify/hljs injectable, fence-closed heuristic + block-projection streaming); Batch 2: chat.js → `lib/transcript.ts` (pure per-session state machine — delta/done/toolStart/toolEnd/merge-group/clearTyping, sid buckets P3, 15 tests) + `components/TranscriptView.tsx` (React chat area: streaming plain-text → markdown-on-done via injectable renderer, tool rows running→done/failed with elapsed + expandable output, merge-group bar summary + collapse/user-expanded, remote label, 7 tests); Batch 3: Sidebar/ResultPanel/WorkspaceView/FileTree components; Batch 4: dialog components (help/memory/skills/rename/confirm/rewind/open-session/new-session/welcome/github-device) + `lib/dialog` reducer + dialogLists/rewind/openSession pure logic; Batch 5: `lib/daemonBridge.ts` event bridge (multi-subscriber onEvent → transcript/app stores, sid-routed #977, queue-injection #655) + `DaemonBridgeProvider` (mount-once/dispose, tRef) + Shell chat-loop wiring (live daemon data: transcript/composer/sidebar/result-panel, #1016-#1018) + `DialogHost` (command routing → dialogs: /help /memory /skills /rewind /rename /delete /sessions /open /resume + direct /clear /compact /version /image; sidebar new-chat/open-chat buttons, #1019) + workspace view switching (Sidebar `#side-nav` rail: sessions/projects/tasks/rants/settings with active highlight; Shell `activeView` state per vanilla `switchView` toggle semantics — panel hides chat chrome, sessions restores it; #1020) + SettingsDialog wiring (`components/SettingsPanel.tsx`: five tabs model/github/appearance/language/about — model CRUD + set-default via getSettings/saveSettings, github connect/disconnect status, theme data-theme apply, i18n setLocale, about version/evolution-count from Shell appState; #1021) - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg Python: `uv run pytest tests/ -v` (1107) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (398: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 12 daemonBridge + 6 DaemonBridgeProvider + 16 Shell + 15 DialogHost) + `npm run build` → `renderer/dist/` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (411: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 12 daemonBridge + 6 DaemonBridgeProvider + 16 Shell + 15 DialogHost + 13 SettingsPanel) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/components/SettingsPanel.test.tsx b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx new file mode 100644 index 00000000..7541920d --- /dev/null +++ b/emrg/gui/renderer/src/components/SettingsPanel.test.tsx @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SettingsPanel } from "./SettingsPanel"; +import { I18nProvider } from "../lib/i18n"; +import * as i18n from "../lib/i18n"; + +/** + * SettingsPanel.test.tsx — Batch 5 slice 6:设置面板接线测试。 + * 模拟 window.emrg 的 getSettings/saveSettings/githubStatus/githubConnect/ + * githubDisconnect IPC,验证: + * - 五个 tab 渲染与切换(model/github/appearance/language/about); + * - 模型服务 tab:getSettings 回填 + 增删改设默认 + saveSettings 载荷; + * - GitHub tab:状态展示 + connect/disconnect 调用; + * - 外观 tab:主题点击落 data-theme 属性;语言 tab:setLocale 写 localStorage; + * - 关于 tab:version/evolutionCount 注入展示; + * - window.emrg 缺失时降级不崩溃(保存 no-op 提示)。 + */ + +function mockEmrg(over: Record = {}) { + const calls: Record = {}; + const fn = (name: string, result: unknown) => { + const mock = vi.fn().mockResolvedValue(result); + calls[name] = []; + mock.mockImplementation(async (...args: unknown[]) => { + calls[name].push(args); + return result; + }); + return mock; + }; + const bridge = { + getSettings: fn("getSettings", { + apiKey: "sk-test", + baseUrl: "https://api.deepseek.com", + model: "deepseek-v3", + theme: "dark", + models: ["deepseek-v3", "gpt-4o"], + modelDetails: [ + { name: "deepseek-v3", model: "deepseek-v3", vision: false }, + { name: "gpt-4o", model: "gpt-4o", vision: true }, + ], + }), + saveSettings: fn("saveSettings", { ok: true }), + githubStatus: fn("githubStatus", { authenticated: true, user: "argszero" }), + githubConnect: fn("githubConnect", { ok: true, user: "argszero" }), + githubDisconnect: fn("githubDisconnect", { ok: true }), + ...over, + }; + (window as unknown as { emrg?: unknown }).emrg = bridge; + return { bridge, calls }; +} + +function setup( + over: Record = {}, + props: { version?: string; evolutionCount?: number | null } = {}, +) { + const { bridge, calls } = mockEmrg(over); + render( + + + , + ); + return { bridge, calls }; +} + +afterEach(() => { + cleanup(); // 显式卸载(本环境 RTL 自动清理不可靠) + vi.restoreAllMocks(); + delete (window as unknown as { emrg?: unknown }).emrg; + document.documentElement.removeAttribute("data-theme"); + try { + localStorage.clear(); + } catch { + /* jsdom 无 localStorage 时忽略 */ + } +}); + +describe("SettingsPanel", () => { + it("renders panel with five tabs, model tab active by default", async () => { + setup(); + expect(screen.getByTestId("panel-settings")).toBeTruthy(); + for (const id of ["model", "github", "appearance", "language", "about"]) { + expect(screen.getByTestId(`settings-tab-${id}`)).toBeTruthy(); + } + // model body visible, others hidden + expect(screen.getByTestId("settings-body-model")).toBeTruthy(); + expect(screen.queryByTestId("settings-body-github")).toBeNull(); + expect(screen.queryByTestId("settings-body-about")).toBeNull(); + }); + + it("switches tabs", async () => { + setup(); + await screen.findByTestId("settings-body-model"); + fireEvent.click(screen.getByTestId("settings-tab-github")); + expect(screen.getByTestId("settings-body-github")).toBeTruthy(); + fireEvent.click(screen.getByTestId("settings-tab-language")); + expect(screen.getByTestId("settings-body-language")).toBeTruthy(); + fireEvent.click(screen.getByTestId("settings-tab-appearance")); + expect(screen.getByTestId("settings-body-appearance")).toBeTruthy(); + fireEvent.click(screen.getByTestId("settings-tab-about")); + expect(screen.getByTestId("settings-body-about")).toBeTruthy(); + expect(screen.queryByTestId("settings-body-model")).toBeNull(); + }); + + it("loads settings from bridge into model tab", async () => { + setup(); + await waitFor(() => + expect((screen.getByTestId("set-api-key") as HTMLInputElement).value).toBe("sk-test"), + ); + expect((screen.getByTestId("set-base-url") as HTMLInputElement).value).toBe( + "https://api.deepseek.com", + ); + // default row (deepseek-v3) + extra row (gpt-4o, deduped) + expect(screen.getAllByTestId("model-item").length).toBe(2); + expect(screen.getByText("deepseek-v3")).toBeTruthy(); + expect(screen.getByText("gpt-4o")).toBeTruthy(); + // theme applied from saved settings + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + }); + + it("adds a model via the form", async () => { + setup(); + await screen.findByTestId("add-model-btn"); + fireEvent.click(screen.getByTestId("add-model-btn")); + expect(screen.getByTestId("model-form")).toBeTruthy(); + fireEvent.change(screen.getByTestId("model-form-name"), { + target: { value: "claude-3-7" }, + }); + fireEvent.change(screen.getByTestId("model-form-id"), { + target: { value: "claude-sonnet" }, + }); + fireEvent.click(screen.getByTestId("model-form-vision")); + fireEvent.click(screen.getByTestId("model-form-save")); + await waitFor(() => expect(screen.getAllByTestId("model-item").length).toBe(3)); + expect(screen.getByText("claude-3-7")).toBeTruthy(); + expect(screen.getByText("claude-sonnet")).toBeTruthy(); + }); + + it("rejects duplicate model name", async () => { + setup(); + await screen.findByTestId("add-model-btn"); + fireEvent.click(screen.getByTestId("add-model-btn")); + fireEvent.change(screen.getByTestId("model-form-name"), { + target: { value: "gpt-4o" }, + }); + fireEvent.click(screen.getByTestId("model-form-save")); + const msg = await screen.findByTestId("settings-msg"); + expect(msg.className).toContain("err"); + // form stays open (not saved) + expect(screen.getByTestId("model-form")).toBeTruthy(); + }); + + it("deletes and sets default on model rows", async () => { + setup(); + await waitFor(() => expect(screen.getAllByTestId("model-item").length).toBe(2)); + // set default on gpt-4o row (second item has radio button) + fireEvent.click(screen.getAllByTestId("model-radio")[0]); + // gpt-4o becomes default: its row now renders model-radio-default + await waitFor(() => expect(screen.queryByTestId("model-radio-default")).toBeTruthy()); + // delete the non-default (deepseek-v3) row + const before = screen.getAllByTestId("model-item").length; + const delButtons = screen + .getAllByTestId("model-item") + .map((row) => row.querySelector('[title="Delete"]')); + const deletable = delButtons.find((b) => b !== null) as HTMLElement | undefined; + if (deletable) { + fireEvent.click(deletable); + await waitFor(() => expect(screen.getAllByTestId("model-item").length).toBe(before - 1)); + } + }); + + it("saves settings through the bridge with merged model list", async () => { + const { calls } = setup(); + await waitFor(() => + expect((screen.getByTestId("set-api-key") as HTMLInputElement).value).toBe("sk-test"), + ); + fireEvent.click(screen.getByTestId("settings-save")); + await waitFor(() => expect(calls.saveSettings.length).toBe(1)); + const payload = calls.saveSettings[0][0] as { + apiKey: string; + baseUrl: string; + model: string; + theme: string; + models: unknown[]; + }; + expect(payload.apiKey).toBe("sk-test"); + expect(payload.baseUrl).toBe("https://api.deepseek.com"); + expect(payload.model).toBe("deepseek-v3"); + expect(payload.theme).toBe("dark"); + expect(payload.models.length).toBe(2); + await waitFor(() => expect(screen.getByTestId("settings-msg").className).toContain("ok")); + }); + + it("blocks save when api key is empty", async () => { + const { calls } = setup({ + getSettings: vi.fn().mockResolvedValue({ + apiKey: "", + baseUrl: "", + model: "", + theme: "system", + modelDetails: [], + }), + }); + await screen.findByTestId("set-api-key"); + fireEvent.click(screen.getByTestId("settings-save")); + const msg = await screen.findByTestId("settings-msg"); + expect(msg.className).toContain("err"); + // no saveSettings call + expect(calls.saveSettings.length).toBe(0); + }); + + it("shows github status and connects/disconnects", async () => { + const { calls } = setup(); + fireEvent.click(screen.getByTestId("settings-tab-github")); + await waitFor(() => + expect(screen.getByTestId("github-status").textContent).toContain("argszero"), + ); + fireEvent.change(screen.getByTestId("set-github-token"), { target: { value: "ghp_x" } }); + fireEvent.click(screen.getByTestId("github-connect-btn")); + await waitFor(() => expect(calls.githubConnect.length).toBe(1)); + expect(calls.githubConnect[0][0]).toEqual({ token: "ghp_x" }); + fireEvent.click(screen.getByTestId("github-disconnect-btn")); + await waitFor(() => expect(calls.githubDisconnect.length).toBe(1)); + await waitFor(() => + expect(screen.getByTestId("github-status").textContent).not.toContain("argszero"), + ); + }); + + it("applies theme option to documentElement", async () => { + setup(); + await screen.findByTestId("settings-tab-appearance"); + fireEvent.click(screen.getByTestId("settings-tab-appearance")); + fireEvent.click(screen.getByTestId("theme-light")); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + fireEvent.click(screen.getByTestId("theme-dark")); + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + fireEvent.click(screen.getByTestId("theme-system")); + expect(document.documentElement.hasAttribute("data-theme")).toBe(false); + }); + + it("sets locale from language tab", async () => { + const spy = vi.spyOn(i18n, "setLocale").mockImplementation((l): i18n.Locale => (l === "" ? "zh" : l)); + setup(); + await screen.findByTestId("settings-tab-language"); + fireEvent.click(screen.getByTestId("settings-tab-language")); + fireEvent.click(screen.getByTestId("lang-zh")); + expect(spy).toHaveBeenCalledWith("zh"); + fireEvent.click(screen.getByTestId("lang-system")); + expect(spy).toHaveBeenCalledWith(""); + }); + + it("renders version and evolution count in about tab", async () => { + setup({}, { version: "0.2.81", evolutionCount: 115 }); + await screen.findByTestId("settings-tab-about"); + fireEvent.click(screen.getByTestId("settings-tab-about")); + const line = screen.getByTestId("about-line").textContent || ""; + expect(line).toContain("0.2.81"); + expect(line).toContain("115"); + }); + + it("degrades gracefully without window.emrg", async () => { + render( + + + , + ); + expect(screen.getByTestId("panel-settings")).toBeTruthy(); + // default model row shows not-configured placeholder + expect(screen.getByText("Not configured")).toBeTruthy(); + // save without bridge → no-op with saved message (api key filled first) + fireEvent.change(screen.getByTestId("set-api-key"), { target: { value: "sk-x" } }); + fireEvent.click(screen.getByTestId("settings-save")); + await waitFor(() => expect(screen.getByTestId("settings-msg").className).toContain("ok")); + }); +}); diff --git a/emrg/gui/renderer/src/components/SettingsPanel.tsx b/emrg/gui/renderer/src/components/SettingsPanel.tsx new file mode 100644 index 00000000..e7ffcdf4 --- /dev/null +++ b/emrg/gui/renderer/src/components/SettingsPanel.tsx @@ -0,0 +1,587 @@ +import { useEffect, useRef, useState } from "react"; +import { useI18n, setLocale } from "../lib/i18n"; + +/** + * SettingsPanel — Batch 5 slice 6:设置面板接线(vanilla dialogs.js showSettings/ + * saveSettings/renderModelList 语义;类名与 vanilla CSS 一致:.panel-tabs/.panel-tab/ + * .panel-tab-body/.settings-group/.model-item/.model-form/.theme-options)。 + * + * - 数据经 window.emrg 加载(getSettings/saveSettings/githubStatus/githubConnect/ + * githubDisconnect);window.emrg 缺失(jsdom / 未接 preload)时降级为表单直显、 + * 保存 no-op —— 与 DialogHost 同款降级策略。 + * - 五个 tab:模型服务 / GitHub / 外观 / 语言 / 关于;model tab 支持 + * 默认模型 + [[llm.models]] 增删改设默认(vanilla renderModelList/saveModelForm 对等)。 + * - 主题三选(vanilla applyTheme 对等:data-theme attr);语言选择(i18n setLocale)。 + */ +export type SettingsTab = "model" | "github" | "appearance" | "language" | "about"; + +export interface SettingsPanelProps { + /** 版本号(Shell appState.currentVersion 注入) */ + version?: string; + /** 进化计数(Shell appState.evolutionCount 注入) */ + evolutionCount?: number | null; +} + +interface ModelRec { + name: string; + model: string; + vision: boolean; +} + +interface GetSettingsResult { + apiKey?: string; + baseUrl?: string; + model?: string; + modelDetails?: { name: string; model?: string; vision?: boolean }[]; + theme?: string; +} + +/** SettingsPanel 用到的 preload 桥方法(与 preload.js 通道对齐) */ +interface EmrgBridge { + getSettings(): Promise; + saveSettings(cfg: unknown): Promise; + githubStatus(): Promise<{ authenticated?: boolean; user?: string | null }>; + githubConnect(p: { token: string }): Promise<{ ok?: boolean; user?: string; error?: string }>; + githubDisconnect(): Promise; +} + +function bridge(): EmrgBridge | undefined { + return (window as unknown as { emrg?: EmrgBridge }).emrg; +} + +const TABS: { id: SettingsTab; labelKey: string }[] = [ + { id: "model", labelKey: "settings.groupModel" }, + { id: "github", labelKey: "settings.groupGithub" }, + { id: "appearance", labelKey: "settings.groupAppearance" }, + { id: "language", labelKey: "settings.groupLanguage" }, + { id: "about", labelKey: "settings.aboutTitle" }, +]; + +export function SettingsPanel({ version = "", evolutionCount = null }: SettingsPanelProps) { + const { t } = useI18n(); + const [tab, setTab] = useState("model"); + // model tab 表单态 + const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [defaultName, setDefaultName] = useState(""); + const [extraModels, setExtraModels] = useState([]); + // 主题 / GitHub 状态 + const [theme, setTheme] = useState("system"); + const [github, setGithub] = useState<{ authenticated: boolean; user: string | null } | null>(null); + const [githubToken, setGithubToken] = useState(""); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + // model 行内表单态(vanilla openModelForm/saveModelForm) + const [formOpen, setFormOpen] = useState(false); + const [editingName, setEditingName] = useState(null); + const [formName, setFormName] = useState(""); + const [formId, setFormId] = useState(""); + const [formVision, setFormVision] = useState(false); + const nameRef = useRef(null); + + // 打开 settings 面板 → 加载设置(vanilla showSettings) + useEffect(() => { + const b = bridge(); + if (!b?.getSettings) return; + b.getSettings() + .then((s) => { + setApiKey(s.apiKey || ""); + setBaseUrl(s.baseUrl || ""); + setDefaultName(s.model || ""); + setTheme(s.theme || "system"); + setExtraModels( + (s.modelDetails || []).map((m) => ({ + name: m.name || "", + model: m.model && m.model !== m.name ? m.model : "", + vision: Boolean(m.vision), + })), + ); + applyTheme(s.theme || "system"); + }) + .catch((e: Error) => setMsg({ kind: "err", text: t("settings.readFailed", { msg: e.message }) })); + void refreshGithubStatus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function refreshGithubStatus() { + const b = bridge(); + if (!b?.githubStatus) return; + try { + const s = await b.githubStatus(); + setGithub({ authenticated: Boolean(s.authenticated), user: s.user || null }); + } catch { + setGithub({ authenticated: false, user: null }); + } + } + + function applyTheme(mode: string) { + const root = document.documentElement; + if (mode === "system" || !mode) root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", mode); + } + + // ── model tab ───────────────────────────── + function openModelForm(name: string | null, isDefault: boolean) { + setEditingName(isDefault ? name : name); + setFormName(name || ""); + const found = extraModels.find((m) => m.name === name); + setFormId(found?.model && found.model !== found.name ? found.model : ""); + setFormVision(Boolean(found?.vision)); + setFormOpen(true); + setTimeout(() => nameRef.current?.focus(), 0); + } + + function closeModelForm() { + setFormOpen(false); + setEditingName(null); + setFormName(""); + setFormId(""); + setFormVision(false); + } + + function saveModelForm() { + const name = formName.trim(); + if (!name) { + setMsg({ kind: "err", text: t("dlg.nameRequiredBody") }); + return; + } + const modelId = formId.trim() || name; + const vision = formVision; + if (editingName === null) { + // 添加新模型 + if (name === defaultName || extraModels.some((m) => m.name === name)) { + setMsg({ kind: "err", text: t("dlg.duplicateBody") }); + return; + } + setExtraModels((prev) => [...prev, { name, model: modelId, vision }]); + } else if (editingName === defaultName) { + // 编辑默认模型:更新默认名 + 配置项 + setExtraModels((prev) => [ + ...prev.filter((m) => m.name !== editingName), + { name, model: modelId, vision }, + ]); + setDefaultName(name); + } else { + if (name === defaultName) { + setMsg({ kind: "err", text: t("dlg.duplicateDefaultBody") }); + return; + } + if (name !== editingName && extraModels.some((m) => m.name === name)) { + setMsg({ kind: "err", text: t("dlg.duplicateBody") }); + return; + } + setExtraModels((prev) => + prev.map((m) => (m.name === editingName ? { name, model: modelId, vision } : m)), + ); + } + closeModelForm(); + } + + function deleteModel(name: string) { + setExtraModels((prev) => prev.filter((m) => m.name !== name)); + } + + function setDefault(name: string) { + setDefaultName(name); + } + + async function saveSettings() { + if (!apiKey.trim()) { + setMsg({ kind: "err", text: t("app.authKeyRequired") }); + return; + } + const b = bridge(); + if (!b?.saveSettings) { + setMsg({ kind: "ok", text: t("dlg.saved") }); + return; + } + setBusy(true); + try { + await b.saveSettings({ + apiKey: apiKey.trim(), + baseUrl: baseUrl.trim(), + model: defaultName, + theme, + models: [ + ...(defaultName && !extraModels.some((m) => m.name === defaultName) + ? [{ name: defaultName, model: defaultName, vision: false }] + : []), + ...extraModels, + ], + }); + setMsg({ kind: "ok", text: t("dlg.saved") }); + } catch (e: unknown) { + setMsg({ kind: "err", text: t("settings.saveFailed", { msg: (e as Error).message }) }); + } finally { + setBusy(false); + } + } + + // ── github tab ──────────────────────────── + async function connectGithub() { + const b = bridge(); + if (!b?.githubConnect) return; + setBusy(true); + try { + const res = await b.githubConnect({ token: githubToken }); + if (res.ok) { + setMsg({ kind: "ok", text: t("settings.githubConnected", { user: res.user || "" }) }); + setGithub({ authenticated: true, user: res.user || null }); + setGithubToken(""); + } else { + setMsg({ kind: "err", text: t("settings.githubConnectFailed", { msg: res.error || t("app.unknownError") }) }); + } + } catch (e: unknown) { + setMsg({ kind: "err", text: t("settings.githubConnectFailed", { msg: (e as Error).message }) }); + } finally { + setBusy(false); + } + } + + async function disconnectGithub() { + const b = bridge(); + if (!b?.githubDisconnect) return; + setBusy(true); + try { + await b.githubDisconnect(); + setMsg({ kind: "ok", text: t("settings.githubDisconnected") }); + setGithub({ authenticated: false, user: null }); + } catch (e: unknown) { + setMsg({ kind: "err", text: t("settings.githubDisconnectFailed", { msg: (e as Error).message }) }); + } finally { + setBusy(false); + } + } + + // ── 渲染 ───────────────────────────────── + const seen = new Set([defaultName]); + + return ( +
+
+

{t("settings.title")}

+
+ {TABS.map(({ id, labelKey }) => ( + + ))} +
+ + {msg && ( +
+ {msg.text} +
+ )} + + {/* ── 模型服务 ── */} + {tab === "model" && ( +
+
+ + +
+ {t("settings.availableModels")} +
+
+ {/* 默认行 */} + m.name === defaultName)?.vision ?? false} + modelId={extraModels.find((m) => m.name === defaultName)?.model ?? ""} + t={t} + onEdit={() => openModelForm(defaultName, true)} + /> + {/* 额外模型行(去重) */} + {extraModels.map((m) => { + if (seen.has(m.name)) return null; + seen.add(m.name); + return ( + setDefault(m.name)} + onEdit={() => openModelForm(m.name, false)} + onDelete={() => deleteModel(m.name)} + /> + ); + })} + {extraModels.filter((m) => m.name !== defaultName).length === 0 && ( +
{t("dlg.noOtherModels")}
+ )} +
+ + {formOpen && ( +
+
+ + +
+
+ +
+ + +
+
+
+ )} +
{t("settings.defaultHint")}
+
+
+ )} + + {/* ── GitHub ── */} + {tab === "github" && ( +
+
+
+ {github === null + ? "—" + : github.authenticated + ? t("settings.githubConnectedStatus", { user: github.user || "" }) + : t("settings.githubNotConnected")} +
+
+ setGithubToken(e.target.value)} + /> + + +
+
{t("settings.githubHint")}
+
+
+ )} + + {/* ── 外观 ── */} + {tab === "appearance" && ( +
+
+ +
+
+ )} + + {/* ── 语言 ── */} + {tab === "language" && ( +
+
+ +
+
+ )} + + {/* ── 关于 ── */} + {tab === "about" && ( +
+
+
+
+ EMRG v{version || "0.2.8"} ·{" "} + + 🌱 {t("copy.growthCountPrefix")} {evolutionCount ?? 0} {t("copy.times")} + +
+
{t("settings.aboutDesc")}
+
+
+
+ )} + +
+ +
+
+
+ ); +} + +/** 模型行(vanilla renderModelItem 对等) */ +function ModelItem({ + name, + isDefault, + vision, + modelId, + t, + onSetDefault, + onEdit, + onDelete, +}: { + name: string; + isDefault: boolean; + vision: boolean; + modelId: string; + t: (k: string, v?: Record) => string; + onSetDefault?: () => void; + onEdit: () => void; + onDelete?: () => void; +}) { + return ( +
+ + + {name} + {isDefault && {t("dlg.defaultBadge")}} + + {modelId && modelId !== name && {modelId}} + {vision && {t("dlg.supportsImages")}} + + + {!isDefault && onDelete && ( + + )} + +
+ ); +} diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 9e37ead4..aea3b236 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -143,7 +143,12 @@ export function Shell() {
{isPanelView ? ( - + ) : ( <> {disconnected ? ( diff --git a/emrg/gui/renderer/src/components/WorkspaceView.tsx b/emrg/gui/renderer/src/components/WorkspaceView.tsx index c8a1be44..3faa64da 100644 --- a/emrg/gui/renderer/src/components/WorkspaceView.tsx +++ b/emrg/gui/renderer/src/components/WorkspaceView.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useI18n } from "../lib/i18n"; +import { SettingsPanel } from "./SettingsPanel"; import { buildEvolveProjects, preprocessRantMarkdown, @@ -34,6 +35,9 @@ export interface WorkspaceViewProps { rants?: RantRec[]; activeView: WorkspaceViewId; onSwitch?: (view: WorkspaceViewId) => void; + /** 设置面板关于 tab 数据(Shell appState 注入,Batch 5 slice 6) */ + version?: string; + evolutionCount?: number | null; onSelectProjectSession?: (project: ProjectRec, sessionId: string) => void; onViewProjectSessions?: (project: ProjectRec) => void; onAddProject?: () => void; @@ -57,6 +61,8 @@ export function WorkspaceView({ rants = [], activeView, onSwitch, + version, + evolutionCount, onSelectProjectSession, onViewProjectSessions, onAddProject, @@ -191,19 +197,7 @@ export function WorkspaceView({ )} {activeView === "settings" && ( -
-

{t("settings.title")}

-
- {["model", "github", "appearance", "language", "about"].map((tab) => ( - - ))} -
-
-
{t("dlg.notConfigured")} — Batch 4
-
-
+ )} );