From 9a8da06c61c8c357de406e8ed21a1b273492a0fb Mon Sep 17 00:00:00 2001 From: xing83 Date: Wed, 19 Aug 2026 20:19:12 +0800 Subject: [PATCH 1/2] feat: add ChatGPT sign-in for AI teacher --- .gitignore | 10 + CONTRIBUTING.md | 1 - README.md | 14 +- README_EN.md | 13 +- docs/MULTI_PROVIDER_MODEL_ACCESS.md | 521 ++++++++++++++++++ package.json | 6 +- pnpm-lock.yaml | 71 +++ scripts/requirements.txt | 1 - scripts/review_game.py | 414 -------------- scripts/start-dev.ps1 | 80 +++ src/main/index.ts | 26 +- src/main/lib/store.ts | 148 ++++- src/main/lib/types.ts | 69 ++- src/main/services/diagnostics/index.ts | 10 +- src/main/services/katago.ts | 22 +- src/main/services/katagoPersistentEngine.ts | 12 +- src/main/services/katagoRuntime.ts | 9 +- src/main/services/llm.ts | 112 ++-- src/main/services/llm/codexAppServerClient.ts | 479 ++++++++++++++++ src/main/services/llm/providerRegistry.ts | 212 +++++++ src/main/services/pythonRuntime.ts | 153 ----- src/main/services/review.ts | 103 ---- src/main/services/systemProfile.ts | 18 +- src/main/services/teacherAgent.ts | 75 ++- src/main/services/teacherSession.ts | 19 +- src/preload/index.ts | 6 +- src/renderer/src/App.tsx | 149 ++++- .../onboarding/FirstRunOnboarding.tsx | 86 ++- .../features/teacher/TeacherComposerPro.tsx | 13 +- .../src/features/teacher/teacher-pro.css | 25 +- src/renderer/src/global.d.ts | 6 +- src/renderer/src/i18n.ts | 3 + tests/first-run-onboarding-contract.test.mjs | 2 +- tests/fox-lazy-library-contract.test.mjs | 5 +- tests/katago-ranking-contract.test.mjs | 15 +- tests/llm-settings-ui-contract.test.mjs | 2 +- tests/multi-provider-llm-contract.test.mjs | 105 ++++ tests/nvidia-release-contract.test.mjs | 2 +- tests/sprint7-ui-polish-contract.test.mjs | 2 +- tests/teacher-agent-runtime-contract.test.mjs | 38 +- ...teacher-current-move-cta-contract.test.mjs | 22 + .../teacher-persona-session-contract.test.mjs | 3 + ...0\346\272\220\347\240\201\347\211\210.cmd" | 3 + 43 files changed, 2181 insertions(+), 904 deletions(-) create mode 100644 docs/MULTI_PROVIDER_MODEL_ACCESS.md delete mode 100644 scripts/requirements.txt delete mode 100644 scripts/review_game.py create mode 100644 scripts/start-dev.ps1 create mode 100644 src/main/services/llm/codexAppServerClient.ts create mode 100644 src/main/services/llm/providerRegistry.ts delete mode 100644 src/main/services/pythonRuntime.ts delete mode 100644 src/main/services/review.ts create mode 100644 tests/multi-provider-llm-contract.test.mjs create mode 100644 tests/teacher-current-move-cta-contract.test.mjs create mode 100644 "\345\220\257\345\212\250\346\272\220\347\240\201\347\211\210.cmd" diff --git a/.gitignore b/.gitignore index f684783..d6deebb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,16 @@ release-evidence .env.* !.env.example +# Local GitHub CLI configuration and portable tooling. +.gh/ +.tools/ +.corepack/ +.goagent-dev-data/ +.electron-cache/ +.python-packages/ +.dev-tools/ +.pnpm-store/ + # Large local KataGo runtime files are not committed. # See data/katago/README.md for the expected packaging layout. data/katago/bin/** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ede7c5e..9f24e77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,6 @@ Thanks for helping build GoAgent. This project sits at the intersection of Go ed ```bash pnpm install -python3 -m pip install -r scripts/requirements.txt pnpm dev ``` diff --git a/README.md b/README.md index 89bd991..05200fa 100644 --- a/README.md +++ b/README.md @@ -167,15 +167,13 @@ docs 架构、发布、签名、公证、QA 文档 - Node.js 22+ - pnpm 10+ -- Python 3.10+ - KataGo 二进制和一个 KataGo 模型 -- 可选:OpenAI-compatible 多模态 LLM API +- 可选:OpenAI-compatible 多模态 LLM API,或通过官方 Codex App Server 使用 ChatGPT 登录 启动: ```bash pnpm install -python3 -m pip install -r scripts/requirements.txt pnpm dev ``` @@ -196,6 +194,12 @@ pnpm dist:win pnpm dist:linux ``` +## AI 老师连接 + +- **API Key**:可继续使用支持 OpenAI-compatible API 的多模态模型服务。 +- **ChatGPT 登录**:在“设置 → AI 老师”选择“使用 ChatGPT 登录”。GoAgent 通过官方 Codex App Server 完成登录、模型发现和请求;可使用当前 ChatGPT 套餐中支持棋盘图片输入的模型。 +- 登录型连接的令牌由官方 Codex 客户端管理;GoAgent 不读取、复制或保存 OAuth token。 + ## KataGo 资源 GoAgent 优先寻找随安装包携带的 KataGo 运行时: @@ -214,8 +218,8 @@ data/katago/ ## 隐私与安全 - 棋谱、学生画像、报告和设置默认保存在 `~/.goagent`。 -- LLM API Key 在支持的平台上使用 Electron `safeStorage` 加密保存。 -- 前端不会拿到已保存的完整 API Key。 +- LLM API Key 保存在 GoAgent 本地 secret store 中;前端不会拿到已保存的完整 Key。 +- ChatGPT 登录凭据和 OAuth token 由官方 Codex App Server 管理,GoAgent 不会读取或保存它们。 - 当前手讲解会发送棋盘截图、KataGo JSON 和知识库摘录到用户配置的 LLM 服务。 - Web 搜索只用于泛化围棋概念,不发送学生姓名、棋谱原文、截图、API Key 或本机路径。 diff --git a/README_EN.md b/README_EN.md index 4193739..7c26408 100644 --- a/README_EN.md +++ b/README_EN.md @@ -122,15 +122,13 @@ Requirements: - Node.js 22+ - pnpm 10+ -- Python 3.10+ - KataGo binary and model -- Optional OpenAI-compatible multimodal LLM API +- Optional OpenAI-compatible multimodal LLM API, or ChatGPT sign-in through the official Codex App Server For remote compute, see [iKataGo Remote Engine](./docs/IKATAGO_REMOTE_ENGINE.md). GoAgent uses a local `ikatago-client -- analysis` process and does not send positions remotely unless the user explicitly enables that engine path. ```bash pnpm install -python3 -m pip install -r scripts/requirements.txt pnpm dev ``` @@ -151,10 +149,17 @@ pnpm dist:win pnpm dist:linux ``` +## AI Teacher Connections + +- **API key**: Continue using any OpenAI-compatible multimodal model service. +- **ChatGPT sign-in**: Choose “Sign in with ChatGPT” under **Settings → AI Teacher**. GoAgent uses the official Codex App Server for sign-in, model discovery, and requests, and can use models in the active ChatGPT plan that accept board images. +- The official Codex client manages sign-in credentials and OAuth tokens. GoAgent never reads, copies, or stores those tokens. + ## Privacy - Games, reports, settings, and student profiles stay under `~/.goagent` by default. -- Saved LLM API keys are encrypted with Electron `safeStorage` when available. +- Saved LLM API keys use GoAgent's local secret store; the renderer never receives the complete saved key. +- The official Codex App Server manages ChatGPT sign-in credentials and OAuth tokens; GoAgent never reads or stores them. - Current-move teaching may send a board screenshot, KataGo JSON, and selected knowledge cards to the configured LLM endpoint. - Web search is optional and should only use generic Go concepts. diff --git a/docs/MULTI_PROVIDER_MODEL_ACCESS.md b/docs/MULTI_PROVIDER_MODEL_ACCESS.md new file mode 100644 index 0000000..bb4a016 --- /dev/null +++ b/docs/MULTI_PROVIDER_MODEL_ACCESS.md @@ -0,0 +1,521 @@ +# 多模型与订阅登录接入方案 + +状态:Phase 0 / Phase 1 已实现;Phase 2+ 待实现 + +目标分支:`design/multi-provider-model-access` + +适用版本:GoAgent `v0.4.20` 之后 + +## 实现记录(当前分支) + +- 已加入 `LlmConnectionProfile`、active connection、版本化迁移和 connection-scoped API Key 存储;旧 `llmBaseUrl/llmApiKey/llmModel` 仍兼容。 +- 已加入 provider registry,原 OpenAI-compatible 工具循环和新 Codex App Server provider 统一从 registry 调用。 +- 已实现 ChatGPT 浏览器登录、账号状态、退出登录、多模态模型发现、文本/棋盘图片输入、流式讲解和取消。 +- ChatGPT 入口先调用 `account/read` 复用 Codex Desktop/CLI 的共享登录缓存;仅在未登录时启动 OAuth。GoAgent 不读取 `auth.json` 或 token。 +- Windows 包内携带官方 `@openai/codex` 平台 CLI 并从 `app.asar.unpacked` 启动,避免 PATH 解析到受保护的 Microsoft Store `WindowsApps` 可执行文件后触发 `spawn EPERM`。 +- ChatGPT 模型完全来自 `model/list`,只展示支持图片的模型并优先采用服务端 `isDefault`;API Key 模式继续从 `/models` 动态刷新。旧的 `gpt-5-mini` 默认值迁移为 GPT-5.6 系列配置,不会串入 ChatGPT profile。 +- ChatGPT provider 采用稳定接口:GoAgent 先确定性运行 KataGo、棋盘截图和知识匹配,再把事实与图片交给当前 provider 生成最终讲解;未启用实验性 dynamic tools。 +- 设置中心和首次引导均可在 API Key 与 ChatGPT 登录之间切换;可为无法从 PATH 发现的环境指定 Codex CLI 路径。 +- 已删除 `review:start`、`review.ts`、`review_game.py`、该脚本专用 Python runtime 与依赖入口。整盘/区间复盘统一进入 `teacherAgent`。 +- Phase 2 的 Claude Code provider 尚未实现。 + +## 已验证场景 + +- 已使用 ChatGPT Pro 账号手工验证:`gpt-5.6-luna` 可完成 GoAgent AI 老师讲解。 +- CI 不会执行真实账号登录;不同 ChatGPT 套餐、模型可用性和跨平台打包仍需要在发布前分别验证。 + +## 1. 背景与结论 + +GoAgent 当前把“模型供应商”“API 协议”和“鉴权方式”绑定在一组设置中: + +```text +llmBaseUrl + llmApiKey + llmModel +``` + +这使 AI 老师只能通过 OpenAI-compatible `chat/completions` + Bearer API Key 工作。虽然项目已经定义了 `LlmProvider` 接口,但教学 Agent 主链仍直接调用 `streamOpenAICompatibleToolTurn`,尚未真正经过 provider registry。 + +本方案建议: + +1. 保留现有 OpenAI-compatible API Key 接入,保证完全向后兼容。 +2. 第一优先级新增“使用 ChatGPT 登录”,通过官方 Codex App Server 承载 OAuth、token 刷新、账号状态、模型发现和模型调用。 +3. 第二优先级新增“使用 Claude Code 登录”,复用用户本机官方 Claude Code 的已登录会话;该接入先作为实验功能发布。 +4. GoAgent 不实现 OpenAI/Anthropic 私有 OAuth,不读取 `~/.codex/auth.json`、系统钥匙串或 Claude Code 凭据,不复制 access token。 +5. 把“是否可用”从 `hasLlmApiKey` 改为能力驱动的连接状态:文字、图片、工具、流式输出分别探测。 + +本文中的“Cloud Code”按用户语境理解为 **Claude Code**。 + +## 2. 已确认的官方能力边界 + +### ChatGPT / Codex + +OpenAI 官方文档明确说明:Codex 本地客户端支持“使用 ChatGPT 登录”获得订阅访问,也支持 API Key 计量访问;Codex App Server 进一步提供: + +- `account/login/start` 的 ChatGPT 浏览器登录和设备码登录; +- `account/read`、`account/logout` 和登录状态通知; +- 自动持久化与刷新 ChatGPT token; +- `model/list` 以及模型的 `inputModalities`; +- `turn/start` 的文字、远程图片和本地图片输入; +- 基于 JSON-RPC/JSONL 的流式事件; +- 实验性的 dynamic tools。 + +参考: + +- [OpenAI authentication](https://developers.openai.com/codex/auth) +- [Codex App Server](https://developers.openai.com/codex/app-server) + +因此 ChatGPT 登录不应被实现成“拿 OAuth token 后伪装 API Key 请求”。正确边界是:GoAgent 作为 App Server 客户端,模型请求也由 App Server 完成。 + +### Claude Code + +Anthropic 官方文档明确说明 Claude Code 可使用 Claude Pro/Max 账号登录,并提供 `claude -p` 非交互模式、流式 JSON 输出和 MCP 工具配置。另一方面,Claude Code SDK 的程序化认证文档仍优先建议专用 API Key。 + +参考: + +- [Set up Claude Code](https://docs.anthropic.com/en/docs/claude-code/getting-started) +- [Claude Code CLI reference](https://docs.anthropic.com/en/docs/claude-code/cli-usage) +- [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk) + +因此 Claude Code 订阅桥接应满足两个约束: + +- 只调用官方 CLI/SDK,不提取或转存凭据; +- 在完成许可、图片输入和 MCP 工具链的真实验收前保持 `experimental`,不可宣称与 API Key 路径完全等价。 + +## 3. 当前实现梳理 + +### 3.1 调用链 + +```mermaid +flowchart LR + UI["设置页 / 首次引导"] --> IPC["Electron IPC"] + IPC --> Store["AppSettings + secretStore"] + UI --> Teacher["teacher:run"] + Teacher --> Agent["teacherAgent.ts"] + Agent --> OA["openaiCompatibleProvider.ts"] + OA --> Endpoint["/chat/completions"] +``` + +主要耦合点: + +- `src/main/lib/types.ts`:`AppSettings` 只有 `llmBaseUrl/llmApiKey/llmModel`。 +- `src/main/lib/store.ts`:secretStore 只保存单个 `llmApiKey`。 +- `src/main/services/teacherAgent.ts`:直接 import OpenAI-compatible 工具轮次函数。 +- `src/main/services/llm.ts`:探测和模型列表固定走 OpenAI-compatible。 +- `src/main/services/review.ts`:Python 复盘子进程只会接收 API Key 参数。 +- `src/main/index.ts` 与 preload:IPC 以“获取已保存 API Key”为中心。 +- `src/renderer/src/App.tsx` 与首次引导:就绪条件写死为 `hasLlmApiKey`。 +- diagnostics/systemProfile/tests/docs:都把“已连接模型”等同于“有 API Key”。 + +### 3.2 已有可复用能力 + +- `ChatMessage`、图片 content part、tool schema 和 `ChatTurnResult` 已形成内部雏形。 +- 现有 provider 已支持文字、图片、工具和流式输出的三项探测。 +- Electron 主进程已经承担 secret 隔离,renderer 默认拿不到完整密钥。 +- teacher runtime 的工具执行、取消、进度和脱敏逻辑可以保留。 + +### 3.3 顺手发现的安全文档偏差 + +README 仍写“API Key 使用 Electron safeStorage”,而当前 `store.ts` 实际使用 app-local AES-256-GCM secret store,并明确不再解密旧 safeStorage 数据。实施时应同步修正文档,避免错误安全承诺。 + +## 4. 目标模型 + +把三个概念拆开: + +| 概念 | 示例 | 说明 | +| --- | --- | --- | +| Provider | OpenAI-compatible、Codex App Server、Claude Code | 谁执行模型请求 | +| Auth mode | API Key、managed login、external CLI session | 凭据如何获得和维护 | +| Model | 具体模型 ID/别名 | 用户最终选择的模型 | + +建议的新设置结构: + +```ts +type LlmProviderId = + | 'openai-compatible' + | 'codex-app-server' + | 'claude-code' + +type LlmAuthMode = + | 'api-key' + | 'managed-login' + | 'external-cli-session' + +interface LlmConnectionProfile { + id: string + name: string + providerId: LlmProviderId + authMode: LlmAuthMode + model: string + endpoint?: string + executablePath?: string + enabled: boolean + options: { + reasoningEffort?: string + timeoutMs?: number + experimentalAgentTools?: boolean + } +} + +interface AppSettings { + activeLlmConnectionId: string + llmConnections: LlmConnectionProfile[] + // 旧字段保留一个迁移周期,随后删除。 + llmBaseUrl: string + llmApiKey: string + llmModel: string +} +``` + +API Key 不进入 profile;secretStore 改为以 connection id 索引: + +```text +llmCredentials..apiKey +``` + +ChatGPT 和 Claude Code 连接只保存非敏感元数据,例如 provider、可执行文件路径、账号显示状态、最后验证时间;OAuth token 由官方客户端管理。 + +## 5. Provider Runtime 设计 + +现有 `LlmProvider` 应升级为真正的运行时边界,而不是只包装 chat: + +```ts +interface ProviderCapabilities { + text: boolean + vision: boolean + tools: boolean + streaming: boolean + modelDiscovery: boolean + managedLogin: boolean +} + +interface ProviderAuthState { + status: 'connected' | 'disconnected' | 'expired' | 'unavailable' | 'unknown' + accountLabel?: string + planLabel?: string + technicalDetail?: string +} + +interface LlmProviderRuntime { + id: LlmProviderId + inspect(profile: LlmConnectionProfile): Promise + beginLogin?(profile: LlmConnectionProfile): Promise + cancelLogin?(loginId: string): Promise + logout?(profile: LlmConnectionProfile): Promise + listModels(profile: LlmConnectionProfile): Promise + probe(profile: LlmConnectionProfile): Promise + runTurn(input: ProviderTurnInput): Promise + cancel?(runId: string): Promise + dispose?(): Promise +} +``` + +新增 `providerRegistry.ts`: + +```text +connection profile + | + v +providerRegistry.resolve(providerId) + | + +-- openAICompatibleRuntime + +-- codexAppServerRuntime + +-- claudeCodeRuntime +``` + +`teacherAgent.ts` 只能依赖 `LlmProviderRuntime.runTurn()`,不得再 import 某一协议的具体函数。 + +## 6. 各接入方式 + +### 6.1 OpenAI-compatible API Key(稳定) + +这是现有实现的平移和兼容层: + +- 继续支持自定义 Base URL、API Key 和模型名; +- 保留 `/models`、文字、图片、tools 的实际探测; +- 保留参数兼容重试和流式降级; +- profile migration 自动创建名为“现有 API 配置”的连接; +- 后续可增加可选的自定义 header,但不放在第一期。 + +### 6.2 ChatGPT 登录 / Codex App Server(推荐新增) + +#### 进程模型 + +Electron main 启动一个受管子进程: + +```text +codex app-server --listen stdio:// +``` + +通过 stdin/stdout 交换 JSONL。GoAgent 维护请求 id、pending promise、通知订阅、进程重启和退出清理。 + +#### 登录流程 + +```mermaid +sequenceDiagram + participant UI as GoAgent UI + participant Main as Electron Main + participant AS as Codex App Server + participant Browser as System Browser + + UI->>Main: 开始 ChatGPT 登录 + Main->>AS: account/login/start + AS-->>Main: authUrl 或 device code + Main-->>UI: 展示登录挑战 + UI->>Browser: 打开官方登录页 + AS-->>Main: account/login/completed + AS-->>Main: account/updated + Main-->>UI: 已连接 + planType +``` + +优先使用 browser flow;回调不稳定、远程桌面或企业环境可切换 device-code flow。 + +#### 模型与多模态 + +- `model/list` 渲染模型选项,只显示 `inputModalities` 包含 `image` 的模型用于完整 AI 老师。 +- 棋盘图通过 `turn/start.input` 的 image/localImage 项发送。 +- 不把 data URL 直接落入日志;需要本地图片时写到 GoAgent 专用临时目录,结束后清理。 +- 最终文字由 agent message delta 和 completed 事件聚合。 + +#### 工具策略 + +Codex App Server 的 dynamic tools 目前是实验 API,所以分两层交付: + +1. 稳定 MVP:GoAgent 先按现有快速路径确定性执行 KataGo、知识检索和棋盘截图,再把完整证据交给 Codex 做单轮/多轮讲解。 +2. 实验开关:将 GoAgent teacher tools 映射为 App Server dynamic tools,支持自由提问的 tool-first 运行时。 + +无论哪一层,Codex 线程都使用隔离的临时 cwd、restricted read access、无写权限、无网络工具权限和 `approvalPolicy: never`。GoAgent 只响应明确注册的教学 dynamic tools,不接受任意 shell/文件操作请求。 + +如果当前 App Server 版本无法满足上述隔离条件,应 fail closed:只允许确定性预取后的讲解,或将该 provider 标记为不可用。 + +### 6.3 Claude Code 登录(实验) + +#### 发现和登录 + +- 查找官方 `claude` 可执行文件,展示路径和版本;允许用户手动指定路径。 +- 未安装时只给官方安装指引,不由 GoAgent 静默安装全局包。 +- 未登录时启动官方 Claude Code 登录体验;GoAgent 不接触凭据。 +- 连接状态通过官方 CLI 的可用探测获得,不读取其凭据文件。 + +#### 调用 + +优先使用官方 SDK/CLI 的非交互流式模式: + +```text +claude -p --output-format stream-json ... +``` + +为了保留 GoAgent 的 tool-first 能力,建议在 Electron main 内启动一个仅暴露教学工具的临时本地 MCP server,并通过临时 `--mcp-config` 交给 Claude Code: + +```text +Claude Code + -> mcp__goagent__katago_analyzePosition + -> mcp__goagent__knowledge_search + -> mcp__goagent__board_captureTeachingImage + -> mcp__goagent__artifact_createTeachingArtifact +``` + +启动参数必须显式 allowlist GoAgent MCP 工具,并禁用 Bash、Write、Edit、WebFetch、WebSearch 等内置能力。MCP server 绑定 loopback/stdio,使用每次运行生成的高熵会话 token,退出时销毁临时配置。 + +#### 发布门槛 + +以下三项必须通过真实账号验收,否则 Claude Code provider 不进入 stable: + +1. Pro/Max 登录态可在非交互调用中合法复用; +2. 棋盘 PNG 能稳定进入模型视觉上下文,而不是只把路径当文字; +3. MCP 工具调用、取消和流式输出在 Windows/macOS/Linux 均可控。 + +## 7. 能力矩阵与降级 + +| Provider | 登录 | 文字 | 图片 | 工具 | 模型发现 | 发布级别 | +| --- | --- | --- | --- | --- | --- | --- | +| OpenAI-compatible | API Key | 探测 | 探测 | 探测 | `/models` 或手填 | stable | +| Codex App Server | ChatGPT/API Key | 支持 | 按模型目录 | dynamic tools 为实验 | `model/list` | login stable / tools beta | +| Claude Code | Claude Pro/Max 或其支持凭据 | 待实测 | 待实测 | MCP | CLI 别名/配置 | experimental | + +每个功能声明自己的最低能力: + +```text +普通问答 text +当前局面视觉讲解 text + vision +自由 Agent 任务 text + tools (+ vision when required) +``` + +连接不满足要求时必须明确提示缺少哪项能力,不可统一显示“API Key 未配置”。 + +## 8. UI / UX + +“设置 > AI 模型”改成连接卡片: + +```text +[ 使用 ChatGPT 登录 ] 推荐 + 未连接 / 已连接 user@example.com · Plus + [登录] [选择模型] [验证] [退出] + +[ 使用 Claude Code ] 实验 + 未安装 / 未登录 / 已连接 + [查看安装指引] [登录] [选择模型] [验证] + +[ 使用 API Key ] + OpenAI-compatible Base URL / API Key / Model + [刷新模型] [验证] +``` + +首次引导同样提供三种入口,并将“稍后配置”保留。就绪条件改为: + +```ts +auth.status === 'connected' && requiredCapabilitiesPassed +``` + +测试结果继续分别展示: + +- 文字回复; +- 棋盘图片; +- Agent 工具; +- 登录状态和计划类型; +- provider 版本/协议版本。 + +renderer 不接收 API Key、OAuth token、CLI credential path 或原始鉴权响应。 + +## 9. IPC 变更 + +建议新增: + +```text +llm:providers:list +llm:connections:list +llm:connections:save +llm:connections:delete +llm:connections:activate +llm:auth:inspect +llm:auth:login-start +llm:auth:login-cancel +llm:auth:logout +llm:models:list +llm:probe +``` + +事件: + +```text +llm:auth:changed +llm:auth:login-progress +llm:provider:status +``` + +删除 renderer 读取完整 API Key 的常规路径。若继续保留“显示 Key”,应单独做用户确认和审计;更建议只提供“替换/清除 Key”。 + +## 10. 数据迁移 + +应用启动时执行幂等迁移: + +1. 如果没有 `llmConnections`,从旧字段创建 `openai-compatible` profile。 +2. 将现有 secretStore 的 `llmApiKey` 移到新 connection id 下。 +3. 设置 `activeLlmConnectionId`。 +4. 旧字段保留一个发行周期供回滚读取,但不再作为主写入源。 +5. 第二个发行周期删除旧字段和 `hasLlmApiKey`,替换为 `activeLlmConnectionStatus`。 + +不要自动把已登录 Codex/Claude 账号加入 GoAgent;必须由用户在 UI 中明确选择连接,以形成清晰的数据发送同意。 + +## 11. 其他调用路径的处理 + +`src/main/services/review.ts` 当前把 API Key 传给 `scripts/review_game.py`。登录型 provider 无法复用这条路径。 + +建议将职责拆成: + +1. Python/KataGo 只生成确定性的分析 JSON; +2. Electron main 再通过 active provider 做讲解总结; +3. 报告合并回现有 artifact。 + +这样所有讲棋入口都经过同一个 provider runtime,也避免把 OAuth/CLI 会话传进 Python 子进程。 + +## 12. 安全与隐私要求 + +- 不读取、复制、打印或备份 Codex/Claude Code credential 文件。 +- 不把 OAuth token 经过 renderer、日志、错误消息、报告或 teacher tool result。 +- API Key 继续只在 main process 使用,并迁移到 connection-scoped secret。 +- 登录必须由用户主动触发,退出必须调用官方 provider 的 logout,而不是只清本地状态。 +- 所有子进程使用参数数组,不通过拼接 shell 命令启动。 +- 临时 PNG、MCP 配置和 JSONL 日志放在 GoAgent 专用临时目录,权限最小化并可回收。 +- provider 崩溃后清理 pending requests、临时文件和本地监听端口。 +- 日志只记录 provider id、模型、能力、耗时、错误码;不记录 prompt、棋盘 base64 和账号 token。 +- 设置页明确提示棋盘截图、KataGo 数据、知识摘录会发送给当前选中的服务。 + +## 13. 实施阶段 + +### Phase 0:抽象与兼容(必须先做) + +- 新 profile schema、迁移和 provider registry; +- OpenAI-compatible adapter 平移; +- teacherAgent、llm.ts、diagnostics、onboarding 全部改用连接/能力状态; +- 拆除 Python LLM 直连; +- 现有测试全部通过。 + +### Phase 1:ChatGPT 登录 MVP + +- Codex App Server 生命周期与 JSON-RPC client; +- browser/device-code 登录、账号状态、退出; +- 模型列表、图片输入、流式讲解、取消; +- 确定性预取的 AI 讲棋路径; +- Windows/macOS/Linux 冒烟测试。 + +### Phase 2:ChatGPT Agent tools beta + +- dynamic tools 映射; +- 工具调用回传、超时、取消和 fail-closed 隔离; +- 自由提问路径真实验收; +- 通过 feature flag 灰度。 + +### Phase 3:Claude Code experimental + +- CLI 发现/登录状态/非交互桥; +- 临时 MCP server 与工具 allowlist; +- 图片输入 POC; +- 订阅账号和三平台验收; +- 根据官方支持边界决定是否升为 stable。 + +## 14. 测试计划 + +### 单元与契约测试 + +- profile schema、旧设置迁移、secret 重键; +- registry 路由,确保 teacherAgent 不再 import 具体 provider; +- Codex JSONL 分帧、乱序响应、通知、进程退出、重连; +- auth challenge、取消、超时、logout; +- model capability 过滤; +- 图片临时文件生命周期; +- Claude stream-json 解析和 MCP allowlist; +- 所有错误与日志的 secret redaction。 + +### 模拟集成测试 + +为三个 provider 分别构造 fake server/process: + +- 文字成功、图片失败、工具失败的独立能力状态; +- token 过期/401 后由官方客户端刷新; +- 子进程崩溃、半行 JSON、超大事件、取消竞态; +- 登录未完成时关闭设置页/退出应用。 + +### 真实验收 + +- ChatGPT Plus/Pro/Business/Enterprise 中至少两个实际计划; +- Claude Pro/Max; +- Windows/macOS/Linux; +- 当前手、整盘总结、自由提问、取消、连续多轮; +- 核对账号用量/限额提示和隐私说明。 + +## 15. 验收标准 + +1. 现有 API Key 用户无感升级,原设置和模型继续可用。 +2. 用户可只用 ChatGPT 登录完成一次带棋盘图片的讲棋,不填写 API Key。 +3. renderer、日志和报告中不存在 OAuth token 或完整 API Key。 +4. provider 切换不需要重启应用,运行中的任务使用启动时冻结的 connection snapshot。 +5. 文字/图片/工具三项能力分别显示并分别阻断对应功能。 +6. provider 进程退出或登录过期时给出可恢复提示,不丢棋谱和学生数据。 +7. Claude Code 在三项发布门槛未满足前始终带“实验”标识。 + +## 16. 推荐决策 + +- **立即实施**:Phase 0 + Phase 1。 +- **保留现有能力**:OpenAI-compatible API Key 始终是稳定 fallback。 +- **不自行做 OAuth**:ChatGPT 走 Codex App Server 托管登录;Claude 走官方 Claude Code 登录态。 +- **工具调用分级**:Codex dynamic tools 先 beta,快速讲棋先用确定性预取保证稳定。 +- **Claude Code 后置**:先做真实 POC,再决定 stable,不以读取 token 或调用未公开接口换取表面可用。 diff --git a/package.json b/package.json index 00d80d3..665445f 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "dist:local:win": "pnpm prepare:katago-transformer && pnpm dist:win", "dist:local:linux": "pnpm prepare:katago-transformer && pnpm dist:linux", "postinstall": "electron-builder install-app-deps", - "prepare:python": "python3 -m pip install -r scripts/requirements.txt", "prepare:katago-assets": "node scripts/prepare_katago_assets.mjs", "prepare:katago-transformer": "node scripts/download_katago_transformer.mjs", "prepare:zhizi-b28": "node scripts/download_zhizi_b28.mjs", @@ -83,6 +82,7 @@ "rc:evidence": "node scripts/collect_release_evidence.mjs --mode=dev" }, "dependencies": { + "@openai/codex": "0.148.0-alpha.20", "electron-store": "^10.0.1", "kokoro-js": "^1.2.1", "openai": "^6.3.0", @@ -143,7 +143,9 @@ ] } ], - "asarUnpack": [], + "asarUnpack": [ + "node_modules/@openai/codex-*/vendor/**/*" + ], "asar": true, "dmg": { "sign": true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59b9626..a0480e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@openai/codex': + specifier: 0.148.0-alpha.20 + version: 0.148.0-alpha.20 electron-store: specifier: ^10.0.1 version: 10.1.0 @@ -1014,6 +1017,47 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@openai/codex@0.148.0-alpha.20': + resolution: {integrity: sha512-HhCLadkgOHpZpF7Y/extF+bcYON6TWfW+K2WzoGRo4lDxFirDmBxP79f3RDeQteDPOaZrNhP78mB4wo48Jifrw==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.148.0-alpha.20-darwin-arm64': + resolution: {integrity: sha512-FKFUVFFXfbb6QZSG6YL/JLXw5pEdP5M5MZaENqxQNSuaRtqOSVMJaQaCztRsmIbp+rjuN0Ulvijy5rXqzGTdWg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.148.0-alpha.20-darwin-x64': + resolution: {integrity: sha512-SLoWN5koL7UZy+kaNR643hfu7mnDBiYHVh8zemsG+YNcfNG9XbZxDRy+1HiVPC3P3MOu0fYcCtdntClUx+fOIg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.148.0-alpha.20-linux-arm64': + resolution: {integrity: sha512-e+Hfp0oVnyKgAd1XJuCzmQz0yjxqPg0ZS2Rg1KEM/b61FrgFg5GuitbC2Mo8a55TSbnKIH6wXBrG+Ok2QveBTg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.148.0-alpha.20-linux-x64': + resolution: {integrity: sha512-VvBQeaKlBwl8Jix1kecyTRTlzWC56oDH9n1VWZktBYAwH1p1kk5WAYW9cAwswss6s+bYpTUvgx/insJpPSmJ4Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.148.0-alpha.20-win32-arm64': + resolution: {integrity: sha512-kDcYq5Bu6gvGF+LsVyotrpOEQTh5Af1TZugyx6eBCn7yJj+TDvlD5V2zN3RJmFEp9AcOWA9WzuknjpFKJhNnqA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.148.0-alpha.20-win32-x64': + resolution: {integrity: sha512-xcvxQrSukCjXz1B8MUO3SUfiCplIrDHkdvCeOaJGVDLve5sAUwTQRx1SNIHACYccaLd+klI6EfD96xywTEqhcQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -4769,6 +4813,33 @@ snapshots: dependencies: semver: 7.7.4 + '@openai/codex@0.148.0-alpha.20': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.148.0-alpha.20-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.148.0-alpha.20-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.148.0-alpha.20-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.148.0-alpha.20-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.148.0-alpha.20-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.148.0-alpha.20-win32-x64' + + '@openai/codex@0.148.0-alpha.20-darwin-arm64': + optional: true + + '@openai/codex@0.148.0-alpha.20-darwin-x64': + optional: true + + '@openai/codex@0.148.0-alpha.20-linux-arm64': + optional: true + + '@openai/codex@0.148.0-alpha.20-linux-x64': + optional: true + + '@openai/codex@0.148.0-alpha.20-win32-arm64': + optional: true + + '@openai/codex@0.148.0-alpha.20-win32-x64': + optional: true + '@oslojs/encoding@1.1.0': {} '@pkgjs/parseargs@0.11.0': diff --git a/scripts/requirements.txt b/scripts/requirements.txt deleted file mode 100644 index 10e0500..0000000 --- a/scripts/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -sgfmill==1.1.1 diff --git a/scripts/review_game.py b/scripts/review_game.py deleted file mode 100644 index fe6f2a8..0000000 --- a/scripts/review_game.py +++ /dev/null @@ -1,414 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import os -import subprocess -import sys -import urllib.error -import urllib.request -from pathlib import Path - -from sgfmill import sgf -from sgfmill import sgf_moves - - -LETTERS = "ABCDEFGHJKLMNOPQRST" - - -def sgf_to_gtp(move, size): - if move is None: - return "pass" - row, col = move - return f"{LETTERS[col]}{size - row}" - - -def normalize_komi(value): - try: - parsed = float(value if value not in (None, "") else 7.5) - except (TypeError, ValueError): - return 7.5 - if abs(parsed) > 150 and parsed.is_integer(): - return parsed / 50 - return parsed - - -def load_game(path): - data = Path(path).read_bytes() - game = sgf.Sgf_game.from_bytes(data) - board, plays = sgf_moves.get_setup_and_moves(game) - size = game.get_size() - root = game.get_root() - - def prop(name, default=""): - try: - value = root.get(name) - except KeyError: - return default - return value if value not in (None, "") else default - - info = { - "size": size, - "komi": normalize_komi(game.get_komi()), - "black": prop("PB", ""), - "white": prop("PW", ""), - "result": prop("RE", ""), - "event": prop("EV", ""), - "date": prop("DT", ""), - } - moves = [] - for color, move in plays: - moves.append((color.upper(), sgf_to_gtp(move, size))) - return info, moves - - -def detect_student_color(info, player_name): - target = (player_name or "").strip().lower() - if not target: - return "B" - if target in (info["black"] or "").lower(): - return "B" - if target in (info["white"] or "").lower(): - return "W" - return "B" - - -class KataGoAnalyzer: - def __init__(self, katago_bin, config_path, model_path, size): - cmd = [ - katago_bin, - "analysis", - "-config", - config_path, - "-model", - model_path, - ] - self.proc = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - ) - self.size = size - - def query(self, moves, komi, max_visits, idx, allow_moves=None): - payload = { - "id": f"query-{idx}", - "moves": moves, - "initialStones": [], - "rules": "Chinese", - "komi": komi, - "boardXSize": self.size, - "boardYSize": self.size, - "maxVisits": max_visits, - } - if allow_moves: - payload["allowMoves"] = allow_moves - self.proc.stdin.write(json.dumps(payload) + "\n") - self.proc.stdin.flush() - line = self.proc.stdout.readline() - if not line: - stderr = self.proc.stderr.read() - raise RuntimeError(f"KataGo did not respond. {stderr}") - return json.loads(line) - - def close(self): - if self.proc.poll() is None: - self.proc.terminate() - try: - self.proc.wait(timeout=3) - except subprocess.TimeoutExpired: - self.proc.kill() - - -def summarize_issue(issue, student_name): - move_no = issue["move_number"] - return ( - f"第 {move_no} 手,{student_name} 下了 {issue['played_move']}," - f"KataGo 更推荐 {issue['best_move']}。这手大约掉了 {issue['loss']:.1f}% 胜率," - f"推荐变化是 {' '.join(issue['pv'][:8])}。" - ) - - -def build_markdown(info, student_name, student_color, issues, language, llm_text): - if language == "en-US": - lines = [ - f"# GoAgent Review: {info['black']} vs {info['white']}", - "", - f"- Student: {student_name or 'auto'} ({student_color})", - f"- Result: {info['result'] or 'Unknown'}", - f"- Date: {info['date'] or 'Unknown'}", - "", - "## Biggest mistakes", - ] - for issue in issues[:5]: - lines.append( - f"- Move {issue['move_number']}: played {issue['played_move']}, KataGo preferred {issue['best_move']}, estimated loss {issue['loss']:.1f}%." - ) - lines.extend(["", "## Coach notes", llm_text or "No LLM notes."]) - return "\n".join(lines) - - lines = [ - f"# GoAgent 复盘报告:{info['black']} vs {info['white']}", - "", - f"- 学生:{student_name or '自动识别'}(执{ '黑' if student_color == 'B' else '白' })", - f"- 结果:{info['result'] or '未知'}", - f"- 日期:{info['date'] or '未知'}", - "", - "## 关键错手", - ] - if issues: - for issue in issues[:5]: - lines.append(f"- {summarize_issue(issue, student_name or '学生')}") - else: - lines.append("- 这一盘没有抓到达到阈值的大失误,可以把阈值再调低继续细看。") - lines.extend( - [ - "", - "## 改进方向", - "- 先看最大掉点的 3 手,不要一口气看完整盘。", - "- 把推荐变化在棋盘上自己摆一遍,确认每一手到底在抢什么。", - "- 如果同类问题反复出现,就单独做一个训练主题,比如方向感、厚薄判断、官子先后手。", - "", - "## 教练讲解", - llm_text or "未启用 LLM,当前报告仅基于 KataGo 数值与变化生成。", - ] - ) - return "\n".join(lines) - - -def is_reasoning_model(model): - lowered = model.lower() - return ( - lowered.startswith("o") - or "gpt-5" in lowered - or "reason" in lowered - or "r1" in lowered - ) - - -def text_from_content(content): - if isinstance(content, str): - return content.strip() - if isinstance(content, list): - parts = [] - for part in content: - if isinstance(part, str): - parts.append(part) - elif isinstance(part, dict): - text = part.get("text") or part.get("content") or "" - if isinstance(text, dict): - text = text.get("value", "") - if isinstance(text, str): - parts.append(text) - return "\n".join(parts).strip() - return "" - - -def extract_llm_text(data): - choices = data.get("choices") or [] - if choices: - choice = choices[0] - message = choice.get("message") or {} - text = text_from_content(message.get("content")) - if text: - return text - if isinstance(choice.get("text"), str) and choice["text"].strip(): - return choice["text"].strip() - if isinstance(data.get("output_text"), str) and data["output_text"].strip(): - return data["output_text"].strip() - output = data.get("output") or [] - if isinstance(output, list): - text = "\n".join( - text_from_content(item.get("content")) - for item in output - if isinstance(item, dict) - ).strip() - if text: - return text - return "" - - -def llm_empty_error(data, model): - choice = (data.get("choices") or [{}])[0] - usage = data.get("usage") or {} - finish_reason = choice.get("finish_reason") or choice.get("native_finish_reason") or "unknown" - usage_fields = { - key: usage[key] - for key in ("prompt_tokens", "completion_tokens", "total_tokens", "output_tokens") - if isinstance(usage, dict) and isinstance(usage.get(key), int) - } - details = usage.get("completion_tokens_details") if isinstance(usage, dict) else None - if isinstance(details, dict) and isinstance(details.get("reasoning_tokens"), int): - usage_fields["reasoning_tokens"] = details["reasoning_tokens"] - return RuntimeError( - f"LLM 没有返回文本内容(model={model}, finish_reason={finish_reason}, usage={json.dumps(usage_fields, ensure_ascii=False)})。" - ) - - -def call_llm(base_url, api_key, model, payload): - max_tokens = 4096 - base_body = { - "model": model, - "messages": [ - { - "role": "system", - "content": "你是顶级围棋教练。请严格依据提供的 KataGo 数据,用通俗中文解释学生为什么错、正确思路是什么、怎么训练。", - }, - { - "role": "user", - "content": json.dumps(payload, ensure_ascii=False), - }, - ], - } - if is_reasoning_model(model): - bodies = [ - {**base_body, "max_completion_tokens": max_tokens, "reasoning_effort": "low"}, - {**base_body, "max_completion_tokens": max_tokens}, - {**base_body, "max_tokens": max_tokens}, - ] - else: - bodies = [ - {**base_body, "temperature": 0.4, "max_completion_tokens": max_tokens}, - {**base_body, "temperature": 0.4, "max_tokens": max_tokens}, - {**base_body, "max_tokens": max_tokens}, - ] - - last_error = "" - for body in bodies: - req = urllib.request.Request( - f"{base_url.rstrip('/')}/chat/completions", - data=json.dumps(body).encode("utf-8"), - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - }, - ) - try: - with urllib.request.urlopen(req, timeout=180) as response: - data = json.loads(response.read().decode("utf-8")) - except urllib.error.HTTPError as error: - error_text = error.read().decode("utf-8", errors="replace") - if error.code == 400 and any( - token in error_text.lower() - for token in ("max_completion_tokens", "max_tokens", "temperature", "reasoning_effort", "unsupported", "unknown parameter") - ): - last_error = error_text[:240] - continue - raise - text = extract_llm_text(data) - if not text: - raise llm_empty_error(data, model) - return text - raise RuntimeError(f"LLM 请求参数不被当前 OpenAI-compatible 服务接受:{last_error}") - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--sgf", required=True) - parser.add_argument("--out-dir", required=True) - parser.add_argument("--katago-bin", required=True) - parser.add_argument("--katago-config", required=True) - parser.add_argument("--katago-model", required=True) - parser.add_argument("--player-name", default="") - parser.add_argument("--max-visits", type=int, default=600) - parser.add_argument("--min-winrate-drop", type=float, default=7.0) - parser.add_argument("--language", default="zh-CN") - parser.add_argument("--llm-base-url", default="") - parser.add_argument("--llm-api-key", default="") - parser.add_argument("--llm-model", default="") - args = parser.parse_args() - - out_dir = Path(args.out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - - info, moves = load_game(args.sgf) - student_color = detect_student_color(info, args.player_name) - analyzer = KataGoAnalyzer(args.katago_bin, args.katago_config, args.katago_model, info["size"]) - issues = [] - - try: - for index, (color, played_move) in enumerate(moves): - if color != student_color: - continue - history = moves[:index] - response = analyzer.query(history, info["komi"], args.max_visits, index) - move_infos = response.get("moveInfos", []) - if not move_infos: - continue - best = move_infos[0] - best_wr_black = float(best.get("winrate", 0.5)) * 100.0 - played_response = analyzer.query( - history, - info["komi"], - args.max_visits, - f"played-{index}", - allow_moves=[{"player": color, "moves": [played_move], "untilDepth": 1}], - ) - played_infos = played_response.get("moveInfos", []) - played_info = next((item for item in played_infos if item.get("move") == played_move), played_infos[0] if played_infos else {}) - played_wr_black = float(played_info.get("winrate", 0.5)) * 100.0 - best_wr = best_wr_black if color == "B" else 100.0 - best_wr_black - played_wr = played_wr_black if color == "B" else 100.0 - played_wr_black - loss = max(0.0, best_wr - played_wr) - if loss < args.min_winrate_drop: - continue - issues.append( - { - "move_number": index + 1, - "played_move": played_move, - "best_move": best.get("move", ""), - "loss": loss, - "best_winrate": best_wr, - "played_winrate": played_wr, - "score_lead": best.get("scoreLead", 0.0), - "pv": best.get("pv", []), - } - ) - finally: - analyzer.close() - - issues.sort(key=lambda item: item["loss"], reverse=True) - summary = { - "student_color": student_color, - "student_name": args.player_name, - "mistake_count": len(issues), - "top_loss": issues[0]["loss"] if issues else 0.0, - "issues": issues[:10], - } - - llm_text = "" - if args.llm_api_key and args.llm_model and args.llm_base_url: - try: - llm_payload = { - "student_color": summary["student_color"], - "student_name": summary["student_name"], - "mistake_count": summary["mistake_count"], - "top_loss": summary["top_loss"], - "issues": summary["issues"][:5], - } - llm_text = call_llm(args.llm_base_url, args.llm_api_key, args.llm_model, llm_payload) - except Exception as exc: - llm_text = f"LLM 讲解生成失败:{exc}" - - markdown = build_markdown(info, args.player_name, student_color, issues, args.language, llm_text) - markdown_path = out_dir / "review.md" - json_path = out_dir / "review.json" - markdown_path.write_text(markdown, encoding="utf-8") - json_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") - - result = { - "markdown_path": str(markdown_path), - "json_path": str(json_path), - "summary": summary, - } - print(json.dumps(result, ensure_ascii=False)) - - -if __name__ == "__main__": - try: - main() - except Exception as exc: - print(str(exc), file=sys.stderr) - sys.exit(1) diff --git a/scripts/start-dev.ps1 b/scripts/start-dev.ps1 new file mode 100644 index 0000000..970092e --- /dev/null +++ b/scripts/start-dev.ps1 @@ -0,0 +1,80 @@ +param( + [switch]$SkipInstall +) + +$ErrorActionPreference = 'Stop' + +$projectRoot = Split-Path -Parent $PSScriptRoot +$dataRoot = Join-Path $projectRoot '.goagent-dev-data' +$kataGoBinary = 'D:\KataGo\katago.exe' +$kataGoModel = 'D:\KataGo\b18c384nbt-humanv0.bin.gz' + +function Require-Command([string]$name) { + if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { + throw "找不到 $name。请先安装它并重新运行此脚本。" + } +} + +Require-Command node +Require-Command corepack + +if (-not (Test-Path -LiteralPath $kataGoBinary)) { + throw "找不到 KataGo 引擎:$kataGoBinary" +} +if (-not (Test-Path -LiteralPath $kataGoModel)) { + throw "找不到 KataGo 模型:$kataGoModel" +} + +# Keep development-only package-manager files and application data outside the release build. +$env:COREPACK_HOME = Join-Path $projectRoot '.corepack' +$env:GOAGENT_APP_HOME = $dataRoot +$env:ELECTRON_CACHE = Join-Path $projectRoot '.electron-cache' +$env:electron_config_cache = $env:ELECTRON_CACHE +# The official GitHub release CDN is often slow or unavailable on mainland networks. +$env:ELECTRON_MIRROR = 'https://npmmirror.com/mirrors/electron/' +New-Item -ItemType Directory -Force -Path $dataRoot | Out-Null + +# The Electron dev launcher invokes `pnpm` itself. Make a local shim so that +# the pinned Corepack pnpm remains available to that child process too. +$toolRoot = Join-Path $projectRoot '.dev-tools' +New-Item -ItemType Directory -Force -Path $toolRoot | Out-Null +$pnpmShim = Join-Path $toolRoot 'pnpm.cmd' +@" +@echo off +corepack pnpm %* +"@ | Set-Content -LiteralPath $pnpmShim -Encoding ascii +$env:PATH = "$toolRoot;$env:PATH" + +# Persist the local KataGo paths for the source build, without overwriting other settings. +$settingsPath = Join-Path $dataRoot 'settings.json' +if (Test-Path -LiteralPath $settingsPath) { + $settings = Get-Content -Raw -LiteralPath $settingsPath | ConvertFrom-Json -AsHashtable +} else { + $settings = @{} +} +if ([string]::IsNullOrWhiteSpace([string]$settings['katagoBin'])) { + $settings['katagoBin'] = $kataGoBinary +} +if ([string]::IsNullOrWhiteSpace([string]$settings['katagoModel'])) { + $settings['katagoModel'] = $kataGoModel +} +# The local OpenCL engine is KataGo v1.15. The default Transformer presets +# require v1.17+, while the supplied b18 model is compatible with this engine. +if ([string]::IsNullOrWhiteSpace([string]$settings['katagoModelPreset']) -or $settings['katagoModelPreset'] -eq 'official-transformer-balanced') { + $settings['katagoModelPreset'] = 'official-b18-recommended' +} +$settings | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $settingsPath -Encoding utf8 + +Set-Location $projectRoot +if (-not $SkipInstall -and -not (Test-Path -LiteralPath (Join-Path $projectRoot 'node_modules\electron\dist\electron.exe'))) { + Write-Host '首次运行:安装 Node.js 依赖…' + corepack pnpm install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { throw 'Node.js 依赖安装失败。' } +} + +Write-Host "启动 GoAgent 源码开发版。开发数据目录:$dataRoot" +# `pnpm dev` delegates to a Node wrapper which launches pnpm again. Invoke the +# Windows development target directly so the pinned local Corepack runtime is +# used reliably even when pnpm is not installed globally. +corepack pnpm exec electron-vite dev +if ($LASTEXITCODE -ne 0) { throw 'GoAgent 开发版启动失败。' } diff --git a/src/main/index.ts b/src/main/index.ts index f80172a..42f5a47 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,7 +17,7 @@ import type { LibraryDeleteRequest, LlmModelsListRequest, LlmSettingsTestRequest, - ReviewRequest, + LlmConnectionActionResult, TeacherBoardImageRenderImage, TeacherBoardImageRenderRequest, TeacherBoardImageRenderResponse, @@ -40,10 +40,10 @@ import type { } from './lib/types' import { importSgfFile, readGameRecord } from './services/sgf' import { ensureFoxGameDownloaded, syncFoxGames } from './services/fox' -import { runReview } from './services/review' import { applyDetectedDefaults, detectSystemProfile } from './services/systemProfile' import { cancelTeacherRun, runTeacherTask } from './services/teacherAgent' import { listLlmModels, testLlmSettings } from './services/llm' +import { disposeLlmProviders, inspectLlmConnection, logoutChatGpt, startChatGptLogin } from './services/llm/providerRegistry' import { analyzeTrialPositionWithProgress, cancelKataGoAnalysis } from './services/katago' import { benchmarkKataGo, cancelKataGoBenchmark, startKataGoBenchmark } from './services/katagoBenchmark' import { getKataGoEnginePoolStats } from './services/katagoEnginePool' @@ -337,15 +337,21 @@ function buildApplicationMenu(): void { async function dashboard(): Promise { const hydratedSettings = await applyDetectedDefaults(getSettings()) replaceSettings(hydratedSettings) - const publicSettings = { ...hydratedSettings, llmApiKey: '', ttsCustomApiKey: '', ttsVolcengineApiKey: '', ttsVolcengineAccessToken: '', ikatagoPassword: '', zhiziToken: '' } const detectedProfile = await detectSystemProfile(hydratedSettings) + const llmConnection = await inspectLlmConnection(hydratedSettings) + if (llmConnection.ready && hydratedSettings.llmSetupStatus !== 'verified') { + setSettings({ llmSetupStatus: 'verified', llmLastVerifiedAt: new Date().toISOString() }) + } + const currentSettings = getSettings() + const publicSettings = { ...currentSettings, llmApiKey: '', ttsCustomApiKey: '', ttsVolcengineApiKey: '', ttsVolcengineAccessToken: '', ikatagoPassword: '', zhiziToken: '' } return { settings: publicSettings, games: getGames(), systemProfile: { ...detectedProfile, proxyApiKey: '', - hasLlmApiKey: hasLlmApiKey() + hasLlmApiKey: hasLlmApiKey(), + llmConnection }, } } @@ -449,7 +455,6 @@ app.whenReady().then(() => { ipcMain.handle('teacher-sessions:update-messages', async (_event, payload: { sessionId: string; messages: TeacherChatMessage[] }) => updateTeacherSessionMessages(payload.sessionId, payload.messages)) ipcMain.handle('teacher-sessions:archive', async (_event, sessionId: string) => archiveTeacherSession(sessionId)) ipcMain.handle('teacher-sessions:delete', async (_event, sessionId: string) => deleteTeacherSession(sessionId)) - ipcMain.handle('review:start', async (_event, payload: ReviewRequest) => runReview(payload)) ipcMain.handle('katago:analyze-position', async (_event, payload: AnalyzePositionRequest) => { const group = payload.group ?? (payload.runId ? 'teacher' : 'single') return runScheduledAnalysis({ @@ -588,6 +593,16 @@ app.whenReady().then(() => { ) ipcMain.handle('llm:test', async (_event, payload: LlmSettingsTestRequest) => testLlmSettings(payload)) ipcMain.handle('llm:list-models', async (_event, payload: LlmModelsListRequest) => listLlmModels(payload)) + ipcMain.handle('llm:chatgpt-login', async (_event, payload?: { useDeviceCode?: boolean }): Promise => { + const login = await startChatGptLogin(Boolean(payload?.useDeviceCode)) + const url = login?.authUrl || login?.verificationUrl + if (url) await shell.openExternal(url) + return { ...(login ? { login } : {}), dashboard: await dashboard() } + }) + ipcMain.handle('llm:chatgpt-logout', async (): Promise => { + await logoutChatGpt() + return { dashboard: await dashboard() } + }) ipcMain.handle('llm:get-saved-api-key', async () => { const settings = getSettings() return { @@ -790,5 +805,6 @@ app.on('window-all-closed', () => { }) app.on('before-quit', () => { + disposeLlmProviders() resetZhiziPersistentSession() }) diff --git a/src/main/lib/store.ts b/src/main/lib/store.ts index dc7bd9b..ee08679 100644 --- a/src/main/lib/store.ts +++ b/src/main/lib/store.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto' import { BRAND_DATA_DIR } from '@shared/brand' -import type { AppSettings, LibraryGame } from './types' +import type { AppSettings, LibraryGame, LlmConnectionProfile } from './types' export const legacyElectronUserData = app.getPath('userData') export const appHome = process.env.GOAGENT_APP_HOME || join(app.getPath('home'), BRAND_DATA_DIR) @@ -14,6 +14,32 @@ export const reviewsDir = join(appHome, 'reviews') export const cacheDir = join(appHome, 'cache') export const reportsDir = join(appHome, 'teacher-reports') +export const LEGACY_LLM_CONNECTION_ID = 'openai-compatible-default' +export const CHATGPT_LLM_CONNECTION_ID = 'chatgpt-codex' +export const DEFAULT_OPENAI_MODEL = 'gpt-5.6-sol' + +function defaultLlmConnections(): LlmConnectionProfile[] { + return [ + { + id: LEGACY_LLM_CONNECTION_ID, + name: 'OpenAI-compatible API', + provider: 'openai-compatible', + authMode: 'api-key', + endpoint: 'https://api.openai.com/v1', + model: DEFAULT_OPENAI_MODEL, + enabled: true + }, + { + id: CHATGPT_LLM_CONNECTION_ID, + name: 'ChatGPT 登录', + provider: 'codex-app-server', + authMode: 'managed-login', + model: '', + enabled: true + } + ] +} + for (const dir of [appHome, electronUserData, libraryDir, reviewsDir, cacheDir, reportsDir]) { mkdirSync(dir, { recursive: true }) } @@ -78,7 +104,10 @@ const defaults: AppSettings = { pythonBin: defaultPythonBin(), llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', - llmModel: 'gpt-5-mini', + llmModel: DEFAULT_OPENAI_MODEL, + activeLlmConnectionId: LEGACY_LLM_CONNECTION_ID, + llmConnections: defaultLlmConnections(), + llmConnectionSchemaVersion: 0, onboardingVersion: 0, llmSetupStatus: 'unconfigured', llmLastVerifiedAt: '', @@ -134,7 +163,7 @@ type SecretValue = | { mode: 'local-v1'; value: string; iv: string; tag: string } | { mode: 'plain'; value: string } -export const secretStore = new Store<{ llmApiKey?: SecretValue; ttsCustomApiKey?: SecretValue; ttsVolcengineApiKey?: SecretValue; ttsVolcengineAccessToken?: SecretValue; ikatagoPassword?: SecretValue; zhiziToken?: SecretValue }>({ +export const secretStore = new Store<{ llmApiKey?: SecretValue; llmApiKeys?: Record; ttsCustomApiKey?: SecretValue; ttsVolcengineApiKey?: SecretValue; ttsVolcengineAccessToken?: SecretValue; ikatagoPassword?: SecretValue; zhiziToken?: SecretValue }>({ name: 'secrets', cwd: appHome, defaults: {} @@ -200,7 +229,7 @@ function decryptSecret(secret?: SecretValue): string { } export function hasLlmApiKey(): boolean { - return decryptSecret(secretStore.get('llmApiKey')).trim().length > 0 + return getLlmApiKey(LEGACY_LLM_CONNECTION_ID).trim().length > 0 } export function hasTtsCustomApiKey(): boolean { @@ -224,12 +253,25 @@ export function hasZhiziToken(): boolean { } function saveLlmApiKey(value: string): void { + saveLlmApiKeyForConnection(LEGACY_LLM_CONNECTION_ID, value) +} + +export function saveLlmApiKeyForConnection(connectionId: string, value: string): void { const trimmed = value.trim() if (trimmed) { - secretStore.set('llmApiKey', encryptSecret(trimmed)) + if (connectionId === LEGACY_LLM_CONNECTION_ID) { + secretStore.set('llmApiKey', encryptSecret(trimmed)) + } + const byConnection = secretStore.get('llmApiKeys', {}) + secretStore.set('llmApiKeys', { ...byConnection, [connectionId]: encryptSecret(trimmed) }) } } +export function getLlmApiKey(connectionId: string): string { + const scoped = secretStore.get('llmApiKeys', {})[connectionId] + return decryptSecret(scoped ?? (connectionId === LEGACY_LLM_CONNECTION_ID ? secretStore.get('llmApiKey') : undefined)) +} + function saveTtsCustomApiKey(value: string): void { const trimmed = value.trim() if (trimmed) { @@ -368,15 +410,60 @@ function migrateZhiziOfficialSettings(settings: AppSettings): AppSettings { return migrated } +function normalizeLlmConnections(settings: AppSettings): AppSettings { + const migratingLegacySettings = settings.llmConnectionSchemaVersion < 1 + const migratingProviderDefaults = settings.llmConnectionSchemaVersion < 2 + const configured = !migratingLegacySettings && Array.isArray(settings.llmConnections) ? settings.llmConnections : [] + const byId = new Map(configured.filter((item) => item && typeof item.id === 'string').map((item) => [item.id, item])) + const legacy = byId.get(LEGACY_LLM_CONNECTION_ID) + const legacyModel = legacy?.model || settings.llmModel || defaults.llmModel + byId.set(LEGACY_LLM_CONNECTION_ID, { + id: LEGACY_LLM_CONNECTION_ID, + name: legacy?.name || 'OpenAI-compatible API', + provider: 'openai-compatible', + authMode: 'api-key', + endpoint: legacy?.endpoint || settings.llmBaseUrl || defaults.llmBaseUrl, + model: migratingProviderDefaults && legacyModel === 'gpt-5-mini' && settings.llmSetupStatus !== 'verified' + ? DEFAULT_OPENAI_MODEL + : legacyModel, + enabled: legacy?.enabled !== false + }) + const chatgpt = byId.get(CHATGPT_LLM_CONNECTION_ID) + byId.set(CHATGPT_LLM_CONNECTION_ID, { + id: CHATGPT_LLM_CONNECTION_ID, + name: chatgpt?.name || 'ChatGPT 登录', + provider: 'codex-app-server', + authMode: 'managed-login', + model: migratingProviderDefaults && chatgpt?.model === 'gpt-5-mini' ? '' : chatgpt?.model || '', + executablePath: chatgpt?.executablePath, + enabled: chatgpt?.enabled !== false + }) + const llmConnections = [...byId.values()] + const activeLlmConnectionId = byId.has(settings.activeLlmConnectionId) + ? settings.activeLlmConnectionId + : LEGACY_LLM_CONNECTION_ID + const migrated = { ...settings, activeLlmConnectionId, llmConnections, llmConnectionSchemaVersion: 2 } + if (migratingProviderDefaults || JSON.stringify(settings.llmConnections) !== JSON.stringify(llmConnections)) { + settingsStore.set({ activeLlmConnectionId, llmConnections, llmConnectionSchemaVersion: 2 }) + } + return migrated +} + export function getSettings(): AppSettings { - const persisted = migrateZhiziOfficialSettings( - migrateZhiziLoginIdentifier( - migrateLocalAnalysisDefault(migratePlaintextSecrets({ ...defaults, ...settingsStore.store })) + const persisted = normalizeLlmConnections( + migrateZhiziOfficialSettings( + migrateZhiziLoginIdentifier( + migrateLocalAnalysisDefault(migratePlaintextSecrets({ ...defaults, ...settingsStore.store })) + ) ) ) + const active = persisted.llmConnections.find((item) => item.id === persisted.activeLlmConnectionId) + const activeApiKey = active?.provider === 'openai-compatible' ? getLlmApiKey(active.id) : '' return { ...persisted, - llmApiKey: decryptSecret(secretStore.get('llmApiKey')), + llmBaseUrl: active?.provider === 'openai-compatible' ? active.endpoint || persisted.llmBaseUrl : persisted.llmBaseUrl, + llmModel: active?.model ?? persisted.llmModel, + llmApiKey: activeApiKey, ttsCustomApiKey: decryptSecret(secretStore.get('ttsCustomApiKey')), ttsVolcengineApiKey: decryptSecret(secretStore.get('ttsVolcengineApiKey')), ttsVolcengineAccessToken: decryptSecret(secretStore.get('ttsVolcengineAccessToken')), @@ -387,7 +474,9 @@ export function getSettings(): AppSettings { export function setSettings(next: Partial): AppSettings { if (typeof next.llmApiKey === 'string') { - saveLlmApiKey(next.llmApiKey) + const current = getSettings() + const targetId = next.activeLlmConnectionId || current.activeLlmConnectionId + saveLlmApiKeyForConnection(targetId, next.llmApiKey) } if (typeof next.ttsCustomApiKey === 'string') { saveTtsCustomApiKey(next.ttsCustomApiKey) @@ -413,6 +502,28 @@ export function setSettings(next: Partial): AppSettings { zhiziToken: _zhiziToken, ...safeNext } = next + const currentBeforeWrite = getSettings() + const legacyFieldsChanged = + Object.prototype.hasOwnProperty.call(next, 'llmBaseUrl') || + Object.prototype.hasOwnProperty.call(next, 'llmModel') + if (!safeNext.llmConnections && legacyFieldsChanged) { + safeNext.llmConnections = currentBeforeWrite.llmConnections.map((connection) => + connection.id === LEGACY_LLM_CONNECTION_ID + ? { + ...connection, + endpoint: typeof next.llmBaseUrl === 'string' ? next.llmBaseUrl : connection.endpoint, + model: typeof next.llmModel === 'string' ? next.llmModel : connection.model + } + : connection + ) + } + if (safeNext.llmConnections) { + const legacy = safeNext.llmConnections.find((connection) => connection.id === LEGACY_LLM_CONNECTION_ID) + if (legacy) { + safeNext.llmBaseUrl = legacy.endpoint || currentBeforeWrite.llmBaseUrl + safeNext.llmModel = legacy.model || currentBeforeWrite.llmModel + } + } delete safeNext.zhiziClientBin delete safeNext.zhiziExtraArgs delete safeNext.zhiziUseWhenLocalSlow @@ -423,10 +534,15 @@ export function setSettings(next: Partial): AppSettings { const llmConfigChanged = Object.prototype.hasOwnProperty.call(next, 'llmBaseUrl') || Object.prototype.hasOwnProperty.call(next, 'llmApiKey') || - Object.prototype.hasOwnProperty.call(next, 'llmModel') + Object.prototype.hasOwnProperty.call(next, 'llmModel') || + Object.prototype.hasOwnProperty.call(next, 'activeLlmConnectionId') || + Object.prototype.hasOwnProperty.call(next, 'llmConnections') if (llmConfigChanged && !Object.prototype.hasOwnProperty.call(next, 'llmSetupStatus')) { const current = getSettings() - const configured = Boolean(current.llmBaseUrl.trim() && current.llmApiKey.trim() && current.llmModel.trim()) + const active = current.llmConnections.find((connection) => connection.id === current.activeLlmConnectionId) + const configured = active?.provider === 'codex-app-server' + ? false + : Boolean(current.llmBaseUrl.trim() && current.llmApiKey.trim() && current.llmModel.trim()) settingsStore.set({ llmSetupStatus: configured ? 'needs-attention' : 'unconfigured', llmLastVerifiedAt: '' @@ -437,7 +553,7 @@ export function setSettings(next: Partial): AppSettings { export function replaceSettings(next: AppSettings): AppSettings { if (next.llmApiKey.trim()) { - saveLlmApiKey(next.llmApiKey) + saveLlmApiKeyForConnection(next.activeLlmConnectionId || LEGACY_LLM_CONNECTION_ID, next.llmApiKey) } if (next.ttsCustomApiKey.trim()) { saveTtsCustomApiKey(next.ttsCustomApiKey) @@ -484,6 +600,12 @@ export function getZhiziToken(): string { return decryptSecret(secretStore.get('zhiziToken')) } +export function getActiveLlmConnection(settings: AppSettings = getSettings()): LlmConnectionProfile { + return settings.llmConnections.find((connection) => connection.id === settings.activeLlmConnectionId) + ?? settings.llmConnections.find((connection) => connection.id === LEGACY_LLM_CONNECTION_ID) + ?? defaultLlmConnections()[0] +} + export function getGames(): LibraryGame[] { return [...libraryStore.get('games', [])].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) } diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index 2265d08..add6ed2 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -1,5 +1,3 @@ -export type ReviewStatus = 'idle' | 'running' | 'done' | 'error' - export type TtsProviderId = 'kokoro-bundled' | 'volcengine-doubao' | 'custom-openai-compatible' | 'custom-http-json' | 'external-local-service' export type TtsReadMode = 'summary' | 'full' | 'selection' export type TtsAudioFormat = 'wav' | 'mp3' | 'pcm' | 'opus' | 'aac' | 'flac' @@ -82,6 +80,30 @@ export interface VisionEvidenceReport { } export type LlmSetupStatus = 'unconfigured' | 'verified' | 'skipped' | 'needs-attention' +export type LlmProviderId = 'openai-compatible' | 'codex-app-server' +export type LlmAuthMode = 'api-key' | 'managed-login' + +export interface LlmConnectionProfile { + id: string + name: string + provider: LlmProviderId + authMode: LlmAuthMode + model: string + endpoint?: string + executablePath?: string + enabled: boolean +} + +export interface LlmConnectionState { + connectionId: string + provider: LlmProviderId + authMode: LlmAuthMode + ready: boolean + status: 'ready' | 'signed-out' | 'unavailable' | 'error' + accountLabel?: string + planLabel?: string + message: string +} export interface AppSettings { katagoBin: string @@ -124,6 +146,9 @@ export interface AppSettings { llmBaseUrl: string llmApiKey: string llmModel: string + activeLlmConnectionId: string + llmConnections: LlmConnectionProfile[] + llmConnectionSchemaVersion: number onboardingVersion: number llmSetupStatus: LlmSetupStatus llmLastVerifiedAt: string @@ -405,6 +430,7 @@ export interface SystemProfile { proxyApiKey: string proxyModels: string[] hasLlmApiKey: boolean + llmConnection: LlmConnectionState hasZhiziToken: boolean notes: string[] } @@ -493,20 +519,6 @@ export interface GameRecord { initialStones?: BoardSetupStone[] } -export interface ReviewArtifact { - markdown: string - summary: Record - jsonPath: string - markdownPath: string -} - -export interface ReviewResult { - game: LibraryGame - status: ReviewStatus - error?: string - artifact?: ReviewArtifact -} - export interface FoxSyncRequest { keyword: string maxGames?: number @@ -655,14 +667,6 @@ export interface ReleaseReadinessResult { flags: ReleaseReadinessFlags } -export interface ReviewRequest { - gameId: string - playerName: string - maxVisits: number - minWinrateDrop: number - useLlm?: boolean -} - export type CoachUserLevel = 'beginner' | 'intermediate' | 'advanced' | 'dan' export type StudentRank = 'sub1d' | '1d' | '2d' | '3d' | '4d' | '5d' | '6d' | '7d' | '8d' | '9d' export type StudentAgeRange = 'unknown' | 'child' | 'teen' | 'adult' | 'senior' @@ -1438,6 +1442,7 @@ export interface LlmSettingsTestRequest { llmBaseUrl: string llmApiKey: string llmModel: string + connectionId?: string } export interface LlmSettingsTestResult { @@ -1459,11 +1464,13 @@ export interface LlmCapabilityCheck { export interface LlmModelsListRequest { llmBaseUrl: string llmApiKey: string + connectionId?: string } export interface LlmModelsListResult { ok: boolean models: string[] + recommendedModel?: string message: string } @@ -1472,6 +1479,20 @@ export interface LlmSavedApiKeyResult { apiKey: string } +export interface LlmLoginStartResult { + connectionId: string + type: 'chatgpt' | 'chatgptDeviceCode' + loginId: string + authUrl?: string + verificationUrl?: string + userCode?: string +} + +export interface LlmConnectionActionResult { + dashboard: DashboardData + login?: LlmLoginStartResult +} + export interface TtsSavedApiKeyResult { hasKey: boolean apiKey: string diff --git a/src/main/services/diagnostics/index.ts b/src/main/services/diagnostics/index.ts index 49a3db6..42d3dc2 100644 --- a/src/main/services/diagnostics/index.ts +++ b/src/main/services/diagnostics/index.ts @@ -2,6 +2,7 @@ import { constants } from 'node:fs' import { access, mkdir, unlink, writeFile } from 'node:fs/promises' import { basename, join } from 'node:path' import { appHome, getSettings, hasLlmApiKey } from '@main/lib/store' +import { inspectLlmConnection } from '@main/services/llm/providerRegistry' import { resolveKataGoRuntime } from '../katagoRuntime' import { ikatagoClientConfigured, shouldPreferIKataGoEngine } from '../ikatagoClientEngine' import { shouldPreferZhiziGtpEngine, zhiziGtpConfigured } from '../zhiziGtpEngine' @@ -236,7 +237,10 @@ async function checkBundledKataGoAssets(): Promise { async function checkLlmProxy(): Promise { const settings = getSettings() - const configured = Boolean(settings.llmBaseUrl.trim() && (settings.llmApiKey.trim() || hasLlmApiKey()) && settings.llmModel.trim()) + const connection = await inspectLlmConnection(settings) + const configured = connection.provider === 'codex-app-server' + ? connection.ready + : Boolean(settings.llmBaseUrl.trim() && (settings.llmApiKey.trim() || hasLlmApiKey()) && settings.llmModel.trim()) if (!configured) { return { id: 'llm-proxy', @@ -244,7 +248,9 @@ async function checkLlmProxy(): Promise { status: 'warn', required: false, detail: '还没有连接 AI 模型。KataGo 分析仍然可以正常使用。', - action: '在“设置 > AI 模型”中填写服务地址、访问密钥和模型。' + action: connection.provider === 'codex-app-server' + ? '在“设置 > AI 模型”中完成 ChatGPT 登录。' + : '在“设置 > AI 模型”中填写服务地址、访问密钥和模型。' } } const verified = settings.llmSetupStatus === 'verified' diff --git a/src/main/services/katago.ts b/src/main/services/katago.ts index ca689c4..0543959 100644 --- a/src/main/services/katago.ts +++ b/src/main/services/katago.ts @@ -736,14 +736,10 @@ async function queryKataGoBatch( schedulePartialResolve() } } catch (error) { - settled = true - clearTimeout(timer) - clearPartialTimer() - child.kill() - cleanup() - engineLease.finish('error') - reject(new Error(`无法解析 KataGo 输出: ${String(error)}\n${line.slice(0, 500)}`)) - return + // KataGo emits fatal diagnostics as plain stdout lines. Preserve the + // whole diagnostic and let the close handler report it. + stderr = `${stderr}${stderr ? '\n' : ''}${line}` + continue } if (results.size >= queries.length) { settled = true @@ -770,6 +766,16 @@ async function queryKataGoBatch( reject(normalizeLocalKataGoProcessError(error, command[0])) }) + child.stdin.on('error', (error) => { + if (settled) return + settled = true + clearTimeout(timer) + clearPartialTimer() + cleanup() + engineLease.finish('error') + reject(new Error(`KataGo 输入通道写入失败: ${error.message}`)) + }) + child.once('close', (code) => { if (settled) { return diff --git a/src/main/services/katagoPersistentEngine.ts b/src/main/services/katagoPersistentEngine.ts index e110543..5991ee0 100644 --- a/src/main/services/katagoPersistentEngine.ts +++ b/src/main/services/katagoPersistentEngine.ts @@ -184,6 +184,11 @@ async function ensureEngineStarted(engine: PersistentEngine): Promise { }, 250) child.stdout.on('data', (chunk) => readStdout(engine, String(chunk))) + child.stdin.on('error', (error) => { + if (engine.child === child) { + restartEngine(engine, new Error(`Persistent KataGo input channel failed: ${error.message}`)) + } + }) child.stderr.on('data', (chunk) => { engine.stderr = (engine.stderr + String(chunk)).slice(-20_000) }) @@ -216,8 +221,11 @@ function readStdout(engine: PersistentEngine, text: string): void { try { parsed = JSON.parse(line) as PersistentKataGoResponse } catch (error) { - restartEngine(engine, new Error(`Unable to parse persistent KataGo output: ${String(error)} ${line.slice(0, 240)}`)) - return + // KataGo writes multi-line fatal diagnostics to stdout. Keep collecting + // them until the process closes so users see the actual engine error + // instead of a misleading JSON parse exception. + engine.stderr = `${engine.stderr}${engine.stderr ? '\n' : ''}${line}`.slice(-20_000) + continue } routeResponse(engine, parsed) } diff --git a/src/main/services/katagoRuntime.ts b/src/main/services/katagoRuntime.ts index 77818f8..33ab621 100644 --- a/src/main/services/katagoRuntime.ts +++ b/src/main/services/katagoRuntime.ts @@ -496,7 +496,11 @@ function defaultAnalysisThreads(): number { return Math.max(1, Math.min(4, os.cpus().length - 2)) } -function ensureAnalysisConfig(settings?: AppSettings): string { +function modelRequiresHumanProfile(modelPath: string): boolean { + return /human/i.test(basename(modelPath)) +} + +function ensureAnalysisConfig(settings?: AppSettings, modelPath = ''): string { const configDir = join(appHome, 'katago', 'configs') mkdirSync(configDir, { recursive: true }) const logDir = join(appHome, 'katago', 'logs') @@ -514,6 +518,7 @@ function ensureAnalysisConfig(settings?: AppSettings): string { 'logSearchInfo = false', 'analysisPVLen = 12', 'reportAnalysisWinratesAs = SIDETOMOVE', + ...(modelRequiresHumanProfile(modelPath) ? ['humanSLProfile = rank_9d'] : []), `numAnalysisThreads = ${analysisThreads}`, `numSearchThreadsPerAnalysisThread = ${searchThreadsPerAnalysisThread}`, `nnMaxBatchSize = ${maxBatchSize}`, @@ -528,8 +533,8 @@ function ensureAnalysisConfig(settings?: AppSettings): string { export function resolveKataGoRuntime(settings?: AppSettings): KataGoRuntime { const modelPreset = getKataGoModelPreset(settings?.katagoModelPreset) const katagoBin = firstExistingBinary([...binaryCandidates(), settings?.katagoBin ?? ''], modelPreset.minimumEngineVersion) - const katagoConfig = ensureAnalysisConfig(settings) const katagoModel = firstExisting(modelCandidates(modelPreset, settings)) + const katagoConfig = ensureAnalysisConfig(settings, katagoModel) const notes: string[] = [] if (katagoBin) { diff --git a/src/main/services/llm.ts b/src/main/services/llm.ts index 7cadea4..0b7b111 100644 --- a/src/main/services/llm.ts +++ b/src/main/services/llm.ts @@ -1,19 +1,14 @@ import type { AppSettings, LlmModelsListRequest, LlmModelsListResult, LlmSettingsTestRequest, LlmSettingsTestResult } from '@main/lib/types' import { getSettings, setSettings } from '@main/lib/store' -import { listOpenAICompatibleModels, postOpenAICompatibleChat, probeOpenAICompatibleProvider, streamOpenAICompatibleChat } from './llm/openaiCompatibleProvider' -import type { ChatMessage, ProviderSettings } from './llm/provider' +import type { ChatMessage } from './llm/provider' +import { listConnectionModels, runProviderTurn, testConnection } from './llm/providerRegistry' type LlmDeltaHandler = (delta: string) => void -function requireProviderSettings(settings: AppSettings): ProviderSettings { - if (!settings.llmBaseUrl.trim() || !settings.llmApiKey.trim() || !settings.llmModel.trim()) { - throw new Error('请先配置支持图片输入的 OpenAI-compatible 多模态 LLM 代理。') - } - return { - llmBaseUrl: settings.llmBaseUrl, - llmApiKey: settings.llmApiKey, - llmModel: settings.llmModel - } +async function callTeacher(settings: AppSettings, messages: ChatMessage[], onDelta?: LlmDeltaHandler): Promise { + const result = await runProviderTurn(settings, messages, [], 4096, onDelta) + if (result.toolCalls.length) throw new Error('当前讲解调用不接受工具请求。') + return result.text } export async function callMultimodalTeacher( @@ -23,23 +18,10 @@ export async function callMultimodalTeacher( imageDataUrl: string, onDelta?: LlmDeltaHandler ): Promise { - const messages: ChatMessage[] = [ - { - role: 'system', - content: systemPrompt - }, - { - role: 'user', - content: [ - { type: 'text', text: textPayload }, - { type: 'image_url', image_url: { url: imageDataUrl } } - ] - } - ] - const providerSettings = requireProviderSettings(settings) - return onDelta - ? streamOpenAICompatibleChat(providerSettings, messages, 4096, onDelta) - : postOpenAICompatibleChat(providerSettings, messages, 4096) + return callTeacher(settings, [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: [{ type: 'text', text: textPayload }, { type: 'image_url', image_url: { url: imageDataUrl } }] } + ], onDelta) } export async function callTeacherText( @@ -48,65 +30,37 @@ export async function callTeacherText( textPayload: string, onDelta?: LlmDeltaHandler ): Promise { - const messages: ChatMessage[] = [ - { - role: 'system', - content: systemPrompt - }, - { - role: 'user', - content: textPayload - } - ] - const providerSettings = requireProviderSettings(settings) - return onDelta - ? streamOpenAICompatibleChat(providerSettings, messages, 4096, onDelta) - : postOpenAICompatibleChat(providerSettings, messages, 4096) + return callTeacher(settings, [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: textPayload } + ], onDelta) } export async function testLlmSettings(payload: LlmSettingsTestRequest): Promise { const saved = getSettings() - const settings = { - llmBaseUrl: payload.llmBaseUrl.trim() || saved.llmBaseUrl, - llmApiKey: payload.llmApiKey.trim() || saved.llmApiKey, - llmModel: payload.llmModel.trim() || saved.llmModel - } - const result = await probeOpenAICompatibleProvider(settings) - const capabilities = result.capabilities ?? { - text: { ok: result.ok, message: result.message, technicalDetail: result.technicalDetail }, - vision: { ok: Boolean(result.supportsImage), message: result.message, technicalDetail: result.technicalDetail }, - tools: { ok: false, message: '尚未验证工具调用。' } - } - const verifiedAt = result.ok ? new Date().toISOString() : '' - setSettings({ - llmSetupStatus: result.ok ? 'verified' : 'needs-attention', - llmLastVerifiedAt: verifiedAt - }) - return { - ok: result.ok, - message: result.message, - capabilities + const connectionId = payload.connectionId || saved.activeLlmConnectionId + const profile = saved.llmConnections.find((item) => item.id === connectionId) + if (profile?.provider === 'openai-compatible') { + setSettings({ + activeLlmConnectionId: connectionId, + llmBaseUrl: payload.llmBaseUrl.trim() || saved.llmBaseUrl, + llmApiKey: payload.llmApiKey.trim(), + llmModel: payload.llmModel.trim() || saved.llmModel + }) } + return testConnection(connectionId) } export async function listLlmModels(payload: LlmModelsListRequest): Promise { const saved = getSettings() - const settings = { - llmBaseUrl: payload.llmBaseUrl.trim() || saved.llmBaseUrl, - llmApiKey: payload.llmApiKey.trim() || saved.llmApiKey - } - try { - const models = await listOpenAICompatibleModels(settings) - return { - ok: true, - models, - message: models.length ? `已刷新 ${models.length} 个模型。` : '代理可访问,但没有返回模型列表。' - } - } catch (error) { - return { - ok: false, - models: [], - message: String(error) - } + const connectionId = payload.connectionId || saved.activeLlmConnectionId + const profile = saved.llmConnections.find((item) => item.id === connectionId) + if (profile?.provider === 'openai-compatible' && (payload.llmBaseUrl.trim() || payload.llmApiKey.trim())) { + setSettings({ + activeLlmConnectionId: connectionId, + llmBaseUrl: payload.llmBaseUrl.trim() || saved.llmBaseUrl, + llmApiKey: payload.llmApiKey.trim() + }) } + return listConnectionModels(connectionId) } diff --git a/src/main/services/llm/codexAppServerClient.ts b/src/main/services/llm/codexAppServerClient.ts new file mode 100644 index 0000000..690edd1 --- /dev/null +++ b/src/main/services/llm/codexAppServerClient.ts @@ -0,0 +1,479 @@ +import { app } from 'electron' +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, sep } from 'node:path' +import { createInterface } from 'node:readline' +import type { LlmConnectionProfile, LlmConnectionState, LlmLoginStartResult } from '@main/lib/types' +import type { ChatMessage, ChatTurnResult } from './provider' + +interface RpcResponse { + id?: number | string + method?: string + params?: Record + result?: unknown + error?: { code?: number; message?: string; data?: unknown } +} + +interface PendingRequest { + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +interface TurnCompletion { + status: string + error?: string +} + +class CodexTransportError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'CodexTransportError' + } +} + +const PLATFORM_TARGETS: Partial>>> = { + win32: { + x64: { packageName: '@openai/codex-win32-x64', triple: 'x86_64-pc-windows-msvc' }, + arm64: { packageName: '@openai/codex-win32-arm64', triple: 'aarch64-pc-windows-msvc' } + }, + darwin: { + x64: { packageName: '@openai/codex-darwin-x64', triple: 'x86_64-apple-darwin' }, + arm64: { packageName: '@openai/codex-darwin-arm64', triple: 'aarch64-apple-darwin' } + }, + linux: { + x64: { packageName: '@openai/codex-linux-x64', triple: 'x86_64-unknown-linux-musl' }, + arm64: { packageName: '@openai/codex-linux-arm64', triple: 'aarch64-unknown-linux-musl' } + } +} + +function unpackedExecutablePath(path: string): string { + if (!app.isPackaged) return path + return path.replace(`${sep}app.asar${sep}`, `${sep}app.asar.unpacked${sep}`) +} + +function bundledCodexExecutable(): string | null { + const target = PLATFORM_TARGETS[process.platform]?.[process.arch] + if (!target) return null + try { + const require = createRequire(import.meta.url) + const codexPackageJson = require.resolve('@openai/codex/package.json') + const codexRequire = createRequire(codexPackageJson) + const platformPackageJson = codexRequire.resolve(`${target.packageName}/package.json`) + const executable = unpackedExecutablePath(join( + dirname(platformPackageJson), + 'vendor', + target.triple, + 'bin', + process.platform === 'win32' ? 'codex.exe' : 'codex' + )) + return existsSync(executable) ? executable : null + } catch { + return null + } +} + +export function resolveCodexExecutable(configuredPath = ''): string { + const explicit = configuredPath.trim() || process.env.GOAGENT_CODEX_BIN?.trim() + if (explicit) return explicit + return bundledCodexExecutable() || 'codex' +} + +function startupError(command: string, error: NodeJS.ErrnoException): Error { + if (process.platform === 'win32' && error.code === 'EPERM') { + return new Error( + '无法启动 Codex CLI:Windows PATH 指向了受保护的 Microsoft Store 应用文件。' + + '请重新安装 GoAgent 的官方 Codex CLI 依赖,或在高级设置中填写可执行的 Codex CLI 路径。' + + `(${command})` + ) + } + if (error.code === 'ENOENT') { + return new Error('未找到可执行的 Codex CLI。请重新安装 GoAgent,或在高级设置中填写 Codex CLI 路径。') + } + return new Error(`无法启动 Codex CLI(${command}):${error.message}`) +} + +function record(value: unknown): Record { + return value && typeof value === 'object' ? value as Record : {} +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function flattenMessages(messages: ChatMessage[]): { text: string; imageUrls: string[] } { + const sections: string[] = [] + const imageUrls: string[] = [] + for (const message of messages) { + const role = message.role === 'system' ? '最高优先级讲解规则' : message.role === 'user' ? '用户与证据' : message.role + if (typeof message.content === 'string') { + if (message.content.trim()) sections.push(`[${role}]\n${message.content}`) + continue + } + const text = message.content.filter((part) => part.type === 'text').map((part) => part.type === 'text' ? part.text : '').join('\n') + if (text.trim()) sections.push(`[${role}]\n${text}`) + for (const part of message.content) { + if (part.type === 'image_url') imageUrls.push(part.image_url.url) + } + } + return { + text: [ + '你是 GoAgent 的围棋讲解 provider。只根据下面给出的 KataGo/棋谱事实和棋盘图片生成最终讲解;不要调用工具、不要修改文件、不要声称重新分析。直接输出可展示的 Markdown。', + ...sections + ].join('\n\n'), + imageUrls + } +} + +function writeDataUrlImage(url: string, directory: string, index: number): string | null { + const match = /^data:(image\/(?:png|jpeg));base64,(.+)$/i.exec(url) + if (!match) return null + const extension = match[1].toLowerCase() === 'image/png' ? 'png' : 'jpg' + const path = join(directory, `board-${index + 1}.${extension}`) + writeFileSync(path, Buffer.from(match[2], 'base64')) + return path +} + +export class CodexAppServerClient { + private child: ChildProcessWithoutNullStreams | null = null + private started: Promise | null = null + private nextId = 1 + private pending = new Map() + private events = new EventEmitter() + private outputByTurn = new Map() + private completionByTurn = new Map() + private stderrTail = '' + + constructor(private executablePath = '') {} + + private async ensureStarted(): Promise { + if (this.started) return this.started + this.started = this.startProcess().catch((error) => { + this.started = null + throw error + }) + return this.started + } + + private async startProcess(): Promise { + const command = resolveCodexExecutable(this.executablePath) + this.stderrTail = '' + const child = spawn(command, ['app-server', '--listen', 'stdio://'], { + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'] + }) + this.child = child + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { + this.stderrTail = `${this.stderrTail}${chunk}`.slice(-4000) + }) + child.on('error', (error) => this.handleProcessFailure(child, startupError(command, error))) + child.stdin.on('error', (error) => { + this.handleProcessFailure(child, new CodexTransportError(`Codex App Server 输入通道已断开:${error.message}`, { cause: error })) + }) + child.once('exit', (code, signal) => { + this.handleProcessFailure(child, new CodexTransportError(`Codex App Server 已退出(code=${code ?? 'null'}, signal=${signal ?? 'null'})。`)) + }) + const lines = createInterface({ input: child.stdout }) + lines.on('line', (line) => this.handleLine(line)) + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', (error) => reject(startupError(command, error))) + }) + await this.request('initialize', { + clientInfo: { + name: 'goagent', + title: 'GoAgent', + version: app.getVersion() + }, + capabilities: { experimentalApi: false } + }, false) + await this.notify('initialized', {}) + } + + private handleLine(line: string): void { + let message: RpcResponse + try { + message = JSON.parse(line) as RpcResponse + } catch { + return + } + if (message.id !== undefined && !message.method) { + const pending = this.pending.get(message.id) + if (!pending) return + this.pending.delete(message.id) + if (message.error) { + pending.reject(new Error(message.error.message || `Codex RPC error ${message.error.code ?? ''}`)) + } else { + pending.resolve(message.result) + } + return + } + if (message.method && message.id !== undefined) { + void this.write({ id: message.id, error: { code: -32601, message: `Unsupported server request: ${message.method}` } }) + .catch(() => undefined) + return + } + if (!message.method) return + const params = record(message.params) + if (message.method === 'item/agentMessage/delta') { + const turnId = stringValue(params.turnId) + const delta = stringValue(params.delta) + if (turnId && delta) { + this.outputByTurn.set(turnId, `${this.outputByTurn.get(turnId) || ''}${delta}`) + this.events.emit(`delta:${turnId}`, delta) + } + } else if (message.method === 'item/completed') { + const item = record(params.item) + if (item.type === 'agentMessage') { + const turnId = stringValue(params.turnId) + const text = stringValue(item.text) + if (turnId && text) this.outputByTurn.set(turnId, text) + } + } else if (message.method === 'turn/completed') { + const turn = record(params.turn) + const turnId = stringValue(turn.id) + const error = record(turn.error) + if (turnId) { + const completion = { status: stringValue(turn.status), error: stringValue(error.message) } + this.completionByTurn.set(turnId, completion) + this.events.emit(`completed:${turnId}`, completion) + } + } + this.events.emit(message.method, params) + } + + private write(message: unknown): Promise { + const child = this.child + const stdin = child?.stdin + if (!stdin || stdin.destroyed || stdin.writableEnded || !stdin.writable) { + return Promise.reject(new CodexTransportError('Codex App Server 未运行或输入通道已关闭。')) + } + return new Promise((resolve, reject) => { + try { + stdin.write(`${JSON.stringify(message)}\n`, (error) => { + if (!error) { + resolve() + return + } + const failure = new CodexTransportError(`Codex App Server 输入通道写入失败:${error.message}`, { cause: error }) + this.handleProcessFailure(child, failure) + reject(failure) + }) + } catch (error) { + const cause = error instanceof Error ? error : new Error(String(error)) + const failure = new CodexTransportError(`Codex App Server 输入通道写入失败:${cause.message}`, { cause }) + this.handleProcessFailure(child, failure) + reject(failure) + } + }) + } + + private notify(method: string, params: Record): Promise { + return this.write({ method, params }) + } + + private async request(method: string, params: Record = {}, ensureStarted = true): Promise { + if (ensureStarted) await this.ensureStarted() + const id = this.nextId++ + const response = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })) + // The transport can fail while the write callback is still pending. Attach a + // rejection observer immediately so Node never reports that pending RPC as + // an unhandled rejection before the write promise settles. + void response.catch(() => undefined) + try { + await this.write({ method, id, params }) + return await response + } catch (error) { + this.pending.delete(id) + throw error + } + } + + private handleProcessFailure(child: ChildProcessWithoutNullStreams, error: Error): void { + if (this.child !== child) return + this.child = null + this.started = null + const stderr = this.stderrTail.trim() + const failure = error instanceof CodexTransportError + ? new CodexTransportError(stderr ? `${error.message}\n${stderr}` : error.message, { cause: error }) + : error + if (child.exitCode === null && !child.killed) child.kill() + this.failAll(failure) + this.events.emit('transport-failure', failure) + } + + private failAll(error: Error): void { + for (const pending of this.pending.values()) pending.reject(error) + this.pending.clear() + } + + private async requestWithRestart(method: string, params: Record = {}): Promise { + try { + return await this.request(method, params) + } catch (error) { + if (!(error instanceof CodexTransportError)) throw error + await this.ensureStarted() + return this.request(method, params) + } + } + + private waitForTurnCompletion(turnId: string): Promise { + return new Promise((resolve, reject) => { + const completedEvent = `completed:${turnId}` + const cleanup = (): void => { + this.events.off(completedEvent, onCompleted) + this.events.off('transport-failure', onTransportFailure) + } + const onCompleted = (completion: TurnCompletion): void => { + cleanup() + resolve(completion) + } + const onTransportFailure = (error: Error): void => { + cleanup() + reject(error) + } + this.events.once(completedEvent, onCompleted) + this.events.once('transport-failure', onTransportFailure) + }) + } + + async connectionState(connectionId: string): Promise { + try { + const result = record(await this.requestWithRestart('account/read', { refreshToken: false })) + const account = record(result.account) + const ready = account.type === 'chatgpt' + return { + connectionId, + provider: 'codex-app-server', + authMode: 'managed-login', + ready, + status: ready ? 'ready' : 'signed-out', + accountLabel: stringValue(account.email) || undefined, + planLabel: stringValue(account.planType) || undefined, + message: ready ? 'ChatGPT 已登录。' : '请登录 ChatGPT 后使用套餐额度讲棋。' + } + } catch (error) { + return { + connectionId, + provider: 'codex-app-server', + authMode: 'managed-login', + ready: false, + status: 'unavailable', + message: String(error) + } + } + } + + async startLogin(connectionId: string, useDeviceCode = false): Promise { + const type = useDeviceCode ? 'chatgptDeviceCode' : 'chatgpt' + const result = record(await this.request('account/login/start', useDeviceCode + ? { type } + : { type, useHostedLoginSuccessPage: true, appBrand: 'chatgpt' })) + return { + connectionId, + type, + loginId: stringValue(result.loginId), + authUrl: stringValue(result.authUrl) || undefined, + verificationUrl: stringValue(result.verificationUrl) || undefined, + userCode: stringValue(result.userCode) || undefined + } + } + + async logout(): Promise { + await this.request('account/logout') + } + + async listModels(): Promise> { + const result = record(await this.requestWithRestart('model/list', { limit: 100, includeHidden: true })) + const data = Array.isArray(result.data) ? result.data : [] + return data.map((entry) => { + const model = record(entry) + const modalities = Array.isArray(model.inputModalities) ? model.inputModalities : ['text', 'image'] + return { + id: stringValue(model.model) || stringValue(model.id), + supportsImage: modalities.includes('image'), + isDefault: model.isDefault === true + } + }).filter((model) => model.id) + } + + async runTurn(profile: LlmConnectionProfile, messages: ChatMessage[], onDelta?: (delta: string) => void, signal?: AbortSignal): Promise { + await this.ensureStarted() + const { text, imageUrls } = flattenMessages(messages) + const tempRoot = mkdtempSync(join(tmpdir(), 'goagent-codex-')) + const input: Array> = [{ type: 'text', text }] + imageUrls.forEach((url, index) => { + const localPath = writeDataUrlImage(url, tempRoot, index) + input.push(localPath ? { type: 'localImage', path: localPath } : { type: 'image', url }) + }) + let threadId = '' + let turnId = '' + const abort = (): void => { + if (threadId && turnId) void this.request('turn/interrupt', { threadId, turnId }).catch(() => undefined) + } + signal?.addEventListener('abort', abort, { once: true }) + try { + const models = await this.listModels() + const model = profile.model || models.find((item) => item.isDefault)?.id || models[0]?.id + const selectedModel = models.find((item) => item.id === model) + if (profile.model && !selectedModel) throw new Error(`当前 ChatGPT 账号没有可用模型:${profile.model}`) + if (imageUrls.length && selectedModel && !selectedModel.supportsImage) { + throw new Error(`模型 ${model} 不支持棋盘图片输入,请选择多模态模型。`) + } + const threadResult = record(await this.request('thread/start', { + ...(model ? { model } : {}), + cwd: tempRoot, + approvalPolicy: 'never', + serviceName: 'goagent' + })) + threadId = stringValue(record(threadResult.thread).id) + if (!threadId) throw new Error('Codex 未返回 thread id。') + const turnResult = record(await this.request('turn/start', { + threadId, + input, + ...(model ? { model } : {}), + cwd: tempRoot, + approvalPolicy: 'never', + // Current App Server versions reject the former readOnly.access shape + // and route restricted reads through permission profiles. GoAgent does + // not expose Codex tools here, so the stable read-only sandbox is enough + // while still allowing the model to consume the localImage input. + sandboxPolicy: { type: 'readOnly' } + })) + const turn = record(turnResult.turn) + turnId = stringValue(turn.id) + if (!turnId) throw new Error('Codex 未返回 turn id。') + if (onDelta) { + const existing = this.outputByTurn.get(turnId) + if (existing) onDelta(existing) + this.events.on(`delta:${turnId}`, onDelta) + } + if (signal?.aborted) abort() + const completion = this.completionByTurn.get(turnId) ?? await this.waitForTurnCompletion(turnId) + if (completion.status !== 'completed') throw new Error(completion.error || `Codex turn ${completion.status}`) + const output = (this.outputByTurn.get(turnId) || '').trim() + if (!output) throw new Error('ChatGPT 没有返回讲解文本。') + return { text: output, toolCalls: [], finishReason: completion.status } + } finally { + if (onDelta && turnId) this.events.off(`delta:${turnId}`, onDelta) + signal?.removeEventListener('abort', abort) + this.outputByTurn.delete(turnId) + this.completionByTurn.delete(turnId) + if (threadId) await this.request('thread/delete', { threadId }).catch(() => undefined) + rmSync(tempRoot, { recursive: true, force: true }) + } + } + + dispose(): void { + const child = this.child + this.child = null + this.started = null + const failure = new CodexTransportError('Codex App Server 客户端已关闭。') + this.failAll(failure) + this.events.emit('transport-failure', failure) + if (child?.exitCode === null && !child.killed) child.kill() + } +} diff --git a/src/main/services/llm/providerRegistry.ts b/src/main/services/llm/providerRegistry.ts new file mode 100644 index 0000000..e7e7763 --- /dev/null +++ b/src/main/services/llm/providerRegistry.ts @@ -0,0 +1,212 @@ +import type { + AppSettings, + LlmConnectionProfile, + LlmConnectionState, + LlmLoginStartResult, + LlmModelsListResult, + LlmSettingsTestResult +} from '@main/lib/types' +import { getActiveLlmConnection, getLlmApiKey, getSettings, setSettings } from '@main/lib/store' +import type { ChatMessage, ChatTool, ChatTurnResult, ProviderSettings } from './provider' +import { listOpenAICompatibleModels, probeOpenAICompatibleProvider, streamOpenAICompatibleToolTurn } from './openaiCompatibleProvider' +import { CodexAppServerClient } from './codexAppServerClient' + +let codexClient: CodexAppServerClient | null = null +let codexExecutablePath = '' + +type CodexModel = { id: string; supportsImage: boolean; isDefault: boolean } + +function clientFor(profile: LlmConnectionProfile): CodexAppServerClient { + const executablePath = profile.executablePath?.trim() || '' + if (!codexClient || executablePath !== codexExecutablePath) { + codexClient?.dispose() + codexExecutablePath = executablePath + codexClient = new CodexAppServerClient(executablePath) + } + return codexClient +} + +export function resolveLlmConnection(settings: AppSettings = getSettings(), connectionId?: string): LlmConnectionProfile { + return settings.llmConnections.find((item) => item.id === connectionId) + ?? getActiveLlmConnection(settings) +} + +function apiSettings(profile: LlmConnectionProfile): ProviderSettings { + const llmApiKey = getLlmApiKey(profile.id) + if (!profile.endpoint?.trim() || !llmApiKey || !profile.model.trim()) { + throw new Error('请先完成 OpenAI-compatible API 地址、API Key 和模型配置。') + } + return { llmBaseUrl: profile.endpoint, llmApiKey, llmModel: profile.model } +} + +function selectCodexVisionModel(profile: LlmConnectionProfile, models: CodexModel[]): CodexModel | undefined { + const visionModels = models.filter((model) => model.supportsImage) + return visionModels.find((model) => model.id === profile.model) + ?? visionModels.find((model) => model.isDefault) + ?? visionModels[0] +} + +function persistConnectionModel(connectionId: string, model: string): void { + const current = getSettings() + const profile = current.llmConnections.find((item) => item.id === connectionId) + if (!profile || profile.model === model) return + setSettings({ + llmConnections: current.llmConnections.map((item) => item.id === connectionId ? { ...item, model } : item) + }) +} + +function recommendedOpenAIModel(models: string[]): string | undefined { + const candidates = models.flatMap((id) => { + const match = /^gpt-(\d+)(?:\.(\d+))?(?:-(sol|terra|luna))?$/i.exec(id) + if (!match) return [] + const tier = match[3]?.toLowerCase() + return [{ id, major: Number(match[1]), minor: Number(match[2] || 0), tier: tier === 'sol' ? 3 : tier === 'terra' ? 2 : tier === 'luna' ? 1 : 4 }] + }) + candidates.sort((left, right) => right.major - left.major || right.minor - left.minor || right.tier - left.tier) + return candidates[0]?.id ?? models.find((id) => /^gpt-/i.test(id)) ?? models[0] +} + +export function activeProviderSupportsTools(settings: AppSettings = getSettings()): boolean { + return getActiveLlmConnection(settings).provider === 'openai-compatible' +} + +export async function inspectLlmConnection(settings: AppSettings = getSettings()): Promise { + const profile = getActiveLlmConnection(settings) + if (profile.provider === 'codex-app-server') { + const state = await clientFor(profile).connectionState(profile.id) + if (!state.ready) return state + try { + const models = await clientFor(profile).listModels() + const selected = selectCodexVisionModel(profile, models) + if (!selected) { + return { + ...state, + ready: false, + status: 'error', + message: '当前 ChatGPT 账号没有可用的多模态模型。' + } + } + persistConnectionModel(profile.id, selected.id) + return state + } catch (error) { + return { ...state, ready: false, status: 'error', message: String(error) } + } + } + const ready = Boolean(profile.endpoint?.trim() && getLlmApiKey(profile.id).trim() && profile.model.trim() && settings.llmSetupStatus === 'verified') + return { + connectionId: profile.id, + provider: profile.provider, + authMode: profile.authMode, + ready, + status: ready ? 'ready' : 'signed-out', + message: ready ? 'OpenAI-compatible API 已验证。' : '请填写并验证 API Key。' + } +} + +export async function testConnection(connectionId?: string): Promise { + const settings = getSettings() + const profile = resolveLlmConnection(settings, connectionId) + if (profile.provider === 'codex-app-server') { + const state = await clientFor(profile).connectionState(profile.id) + let models: CodexModel[] = [] + if (state.ready) models = await clientFor(profile).listModels() + const selected = selectCodexVisionModel(profile, models) + const hasVision = Boolean(selected?.supportsImage) + const ok = state.ready && hasVision + if (ok && selected) persistConnectionModel(profile.id, selected.id) + setSettings({ llmSetupStatus: ok ? 'verified' : 'needs-attention', llmLastVerifiedAt: ok ? new Date().toISOString() : '' }) + return { + ok, + message: ok ? 'ChatGPT 登录有效,且当前模型支持图片输入。' : state.ready ? 'ChatGPT 已登录,但所选模型不可用或不支持图片输入。' : state.message, + capabilities: { + text: { ok: state.ready, message: state.ready ? 'ChatGPT 文本访问可用。' : state.message }, + vision: { ok: hasVision, message: hasVision ? '发现支持图片输入的模型。' : '未发现图片输入能力。' }, + tools: { ok: true, message: 'GoAgent 将先运行本地 KataGo 工具,再由 ChatGPT 统一讲解。' } + } + } + } + const result = await probeOpenAICompatibleProvider(apiSettings(profile)) + const capabilities = result.capabilities ?? { + text: { ok: result.ok, message: result.message, technicalDetail: result.technicalDetail }, + vision: { ok: Boolean(result.supportsImage), message: result.message, technicalDetail: result.technicalDetail }, + tools: { ok: false, message: '尚未验证工具调用。' } + } + setSettings({ llmSetupStatus: result.ok ? 'verified' : 'needs-attention', llmLastVerifiedAt: result.ok ? new Date().toISOString() : '' }) + return { ok: result.ok, message: result.message, capabilities } +} + +export async function listConnectionModels(connectionId?: string): Promise { + const settings = getSettings() + const profile = resolveLlmConnection(settings, connectionId) + try { + if (profile.provider === 'codex-app-server') { + const available = (await clientFor(profile).listModels()).filter((model) => model.supportsImage) + const selected = selectCodexVisionModel(profile, available) + if (selected) persistConnectionModel(profile.id, selected.id) + const models = selected + ? [selected.id, ...available.filter((model) => model.id !== selected.id).map((model) => model.id)] + : available.map((model) => model.id) + return { + ok: true, + models, + recommendedModel: selected?.id, + message: models.length ? `已从当前 ChatGPT 账号刷新 ${models.length} 个多模态模型。` : '当前账号没有返回多模态模型。' + } + } + const models = await listOpenAICompatibleModels(apiSettings(profile)) + return { + ok: true, + models, + recommendedModel: recommendedOpenAIModel(models), + message: models.length ? `已从模型接口刷新 ${models.length} 个模型。` : '连接可用,但没有返回模型列表。' + } + } catch (error) { + return { ok: false, models: [], message: String(error) } + } +} + +export async function startChatGptLogin(useDeviceCode = false): Promise { + const settings = getSettings() + const profile = settings.llmConnections.find((item) => item.provider === 'codex-app-server') + if (!profile) throw new Error('ChatGPT provider 配置不存在。') + setSettings({ activeLlmConnectionId: profile.id, llmSetupStatus: 'needs-attention', llmLastVerifiedAt: '' }) + const state = await clientFor(profile).connectionState(profile.id) + if (state.ready) { + const models = await clientFor(profile).listModels() + const selected = selectCodexVisionModel(profile, models) + if (!selected) throw new Error('当前 ChatGPT 账号没有可用的多模态模型。') + persistConnectionModel(profile.id, selected.id) + setSettings({ llmSetupStatus: 'verified', llmLastVerifiedAt: new Date().toISOString() }) + return undefined + } + if (state.status === 'unavailable') throw new Error(state.message) + return clientFor(profile).startLogin(profile.id, useDeviceCode) +} + +export async function logoutChatGpt(): Promise { + const profile = getSettings().llmConnections.find((item) => item.provider === 'codex-app-server') + if (!profile) return + await clientFor(profile).logout() + setSettings({ llmSetupStatus: 'unconfigured', llmLastVerifiedAt: '' }) +} + +export async function runProviderTurn( + settings: AppSettings, + messages: ChatMessage[], + tools: ChatTool[], + maxTokens: number, + onDelta?: (delta: string) => void, + signal?: AbortSignal +): Promise { + const profile = getActiveLlmConnection(settings) + if (profile.provider === 'codex-app-server') { + return clientFor(profile).runTurn(profile, messages, onDelta, signal) + } + return streamOpenAICompatibleToolTurn(apiSettings(profile), messages, tools, maxTokens, onDelta, signal) +} + +export function disposeLlmProviders(): void { + codexClient?.dispose() + codexClient = null + codexExecutablePath = '' +} diff --git a/src/main/services/pythonRuntime.ts b/src/main/services/pythonRuntime.ts deleted file mode 100644 index b552373..0000000 --- a/src/main/services/pythonRuntime.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { createHash } from 'node:crypto' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import { devNull } from 'node:os' -import { appHome } from '@main/lib/store' - -const execFileAsync = promisify(execFile) - -const runtimeRoot = join(appHome, 'runtime') -const venvRoot = join(runtimeRoot, 'venv') -const venvBinDir = process.platform === 'win32' ? 'Scripts' : 'bin' -const pythonPath = join(venvRoot, venvBinDir, process.platform === 'win32' ? 'python.exe' : 'python3') -const stampPath = join(runtimeRoot, 'requirements.sha256') - -interface PythonLauncher { - command: string - args: string[] - label: string -} - -async function pathExists(target: string): Promise { - try { - await access(target) - return true - } catch { - return false - } -} - -function parseConfiguredPython(value?: string): PythonLauncher[] { - const trimmed = value?.trim() ?? '' - if (!trimmed) return [] - if (/^py(?:\.exe)?(?:\s|$)/i.test(trimmed)) { - const [, ...args] = trimmed.split(/\s+/) - return [{ command: 'py', args: args.length ? args : ['-3'], label: trimmed }] - } - return [{ command: trimmed, args: [], label: trimmed }] -} - -function pythonLaunchers(preferredPythonBin?: string): PythonLauncher[] { - const candidates: PythonLauncher[] = [ - ...parseConfiguredPython(preferredPythonBin), - ...parseConfiguredPython(process.env.PYTHON) - ] - if (process.platform === 'win32') { - candidates.push( - { command: 'python', args: [], label: 'python' }, - { command: 'py', args: ['-3'], label: 'py -3' }, - { command: 'python3', args: [], label: 'python3' } - ) - } else { - candidates.push( - { command: 'python3', args: [], label: 'python3' }, - { command: 'python', args: [], label: 'python' } - ) - } - const seen = new Set() - return candidates.filter((candidate) => { - const key = `${candidate.command}\0${candidate.args.join('\0')}`.toLowerCase() - if (seen.has(key)) return false - seen.add(key) - return true - }) -} - -async function probePythonLauncher(candidate: PythonLauncher): Promise { - try { - const { stdout } = await execFileAsync(candidate.command, [ - ...candidate.args, - '-c', - 'import sys; print(sys.version_info[0]); print(sys.executable)' - ], { windowsHide: true, timeout: 10_000 }) - const [majorText] = stdout.trim().split(/\r?\n/) - return Number(majorText) >= 3 - } catch { - return false - } -} - -function pipFallbackEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - PIP_CONFIG_FILE: devNull, - PIP_INDEX_URL: 'https://pypi.org/simple', - PIP_DISABLE_PIP_VERSION_CHECK: '1', - PIP_NO_INPUT: '1' - } -} - -async function runPython(args: string[], usePipFallback = false): Promise { - if (usePipFallback) { - try { - await execFileAsync(pythonPath, args, { - windowsHide: true, - timeout: 120_000, - env: pipFallbackEnv() - }) - return - } catch (firstError) { - try { - await execFileAsync(pythonPath, args, { windowsHide: true, timeout: 120_000 }) - } catch (secondError) { - throw new Error(`Python 依赖安装失败。官方 PyPI 错误:${String(firstError)};默认 pip 源也失败:${String(secondError)}`) - } - } - return - } - try { - await execFileAsync(pythonPath, args, { windowsHide: true, timeout: 120_000 }) - } catch (error) { - throw error - } -} - -export async function resolvePythonLauncher(preferredPythonBin?: string): Promise { - const candidates = pythonLaunchers(preferredPythonBin) - for (const candidate of candidates) { - if (await probePythonLauncher(candidate)) { - return candidate - } - } - throw new Error(`找不到可用的 Python 3。已尝试:${candidates.map((candidate) => candidate.label).join('、')}。请在设置里把 Python 路径改为 python.exe,或安装 Python 3。`) -} - -export async function ensurePythonRuntime(projectRoot: string, preferredPythonBin?: string): Promise { - await mkdir(runtimeRoot, { recursive: true }) - - let createdVenv = false - if (!(await pathExists(pythonPath))) { - const launcher = await resolvePythonLauncher(preferredPythonBin) - await execFileAsync(launcher.command, [...launcher.args, '-m', 'venv', venvRoot], { windowsHide: true }) - createdVenv = true - } - - if (!(await pathExists(pythonPath))) { - throw new Error(`Python 虚拟环境创建失败:没有找到 ${pythonPath}`) - } - - const requirementsPath = join(projectRoot, 'scripts', 'requirements.txt') - const requirements = await readFile(requirementsPath, 'utf8') - const digest = createHash('sha256').update(requirements).digest('hex') - const installedDigest = (await pathExists(stampPath)) ? (await readFile(stampPath, 'utf8')).trim() : '' - - if (createdVenv || installedDigest !== digest) { - await runPython(['-m', 'ensurepip', '--upgrade']) - await runPython(['-m', 'pip', 'install', '-r', requirementsPath], true) - await writeFile(stampPath, `${digest}\n`, 'utf8') - } - - return pythonPath -} diff --git a/src/main/services/review.ts b/src/main/services/review.ts deleted file mode 100644 index 879d768..0000000 --- a/src/main/services/review.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { spawn } from 'node:child_process' -import { mkdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { findGame, getSettings, reviewsDir } from '@main/lib/store' -import type { ReviewArtifact, ReviewRequest, ReviewResult } from '@main/lib/types' -import { resolveKataGoRuntime } from './katagoRuntime' -import { ensurePythonRuntime } from './pythonRuntime' -import { ensureFoxGameDownloaded } from './fox' - -interface PythonReviewOutput { - markdown_path: string - json_path: string - summary: Record -} - -export async function runReview(request: ReviewRequest): Promise { - const indexedGame = findGame(request.gameId) - if (!indexedGame) { - throw new Error('找不到要复盘的棋谱') - } - const game = await ensureFoxGameDownloaded(indexedGame) - - const settings = getSettings() - const runtime = resolveKataGoRuntime(settings) - if (!runtime.ready) { - throw new Error(`${runtime.status}: ${runtime.notes.join(';')}`) - } - const pythonBin = await ensurePythonRuntime(process.cwd(), settings.pythonBin) - const reviewRoot = join(reviewsDir, game.id) - mkdirSync(reviewRoot, { recursive: true }) - - const args = [ - join(process.cwd(), 'scripts', 'review_game.py'), - '--sgf', - game.filePath, - '--out-dir', - reviewRoot, - '--katago-bin', - runtime.katagoBin, - '--katago-config', - runtime.katagoConfig, - '--katago-model', - runtime.katagoModel, - '--player-name', - request.playerName.trim() || settings.defaultPlayerName.trim() || game.black, - '--max-visits', - String(request.maxVisits), - '--min-winrate-drop', - String(request.minWinrateDrop), - '--language', - settings.reviewLanguage - ] - - if (request.useLlm !== false && settings.llmApiKey.trim()) { - args.push('--llm-base-url', settings.llmBaseUrl.trim()) - args.push('--llm-api-key', settings.llmApiKey.trim()) - args.push('--llm-model', settings.llmModel.trim()) - } - - const output = await new Promise((resolve, reject) => { - const child = spawn(pythonBin, args, { - cwd: process.cwd(), - env: { ...process.env } - }) - - let stdout = '' - let stderr = '' - - child.stdout.on('data', (chunk) => { - stdout += String(chunk) - }) - - child.stderr.on('data', (chunk) => { - stderr += String(chunk) - }) - - child.on('error', reject) - child.on('close', (code) => { - if (code !== 0) { - reject(new Error(stderr.trim() || stdout.trim() || `review_game.py exited with ${code}`)) - return - } - try { - resolve(JSON.parse(stdout) as PythonReviewOutput) - } catch (error) { - reject(new Error(`无法解析复盘输出: ${String(error)}\n${stdout}`)) - } - }) - }) - - const artifact: ReviewArtifact = { - markdown: readFileSync(output.markdown_path, 'utf8'), - markdownPath: output.markdown_path, - jsonPath: output.json_path, - summary: output.summary - } - - return { - game, - status: 'done', - artifact - } -} diff --git a/src/main/services/systemProfile.ts b/src/main/services/systemProfile.ts index 2c28abe..dce808a 100644 --- a/src/main/services/systemProfile.ts +++ b/src/main/services/systemProfile.ts @@ -128,6 +128,14 @@ export async function detectSystemProfile(settings?: AppSettings): Promise item.id === settings.activeLlmConnectionId)?.provider ?? 'openai-compatible', + authMode: settings?.llmConnections.find((item) => item.id === settings.activeLlmConnectionId)?.authMode ?? 'api-key', + ready: false, + status: 'signed-out', + message: '尚未检查 LLM 连接。' + }, hasZhiziToken: Boolean(settings?.zhiziToken.trim()), notes: [...katago.notes, ...proxy.notes], } @@ -136,7 +144,15 @@ export async function detectSystemProfile(settings?: AppSettings): Promise { const hydratedKatago = hydrateKataGoSettings(settings) const detected = await detectSystemProfile(hydratedKatago) + const activeLlmConnection = settings.llmConnections.find((connection) => connection.id === settings.activeLlmConnectionId) + if (activeLlmConnection && activeLlmConnection.provider !== 'openai-compatible') { + return hydratedKatago + } const preferredModel = + detected.proxyModels.find((model) => model === 'gpt-5.6') || + detected.proxyModels.find((model) => model === 'gpt-5.6-sol') || + detected.proxyModels.find((model) => model === 'gpt-5.6-terra') || + detected.proxyModels.find((model) => model === 'gpt-5.6-luna') || detected.proxyModels.find((model) => model === 'gpt-5.5') || detected.proxyModels.find((model) => model === 'gpt-5.4-mini') || detected.proxyModels.find((model) => model === 'gpt-5-codex-mini') || @@ -148,7 +164,7 @@ export async function applyDetectedDefaults(settings: AppSettings): Promise void type TeacherBoardImageCaptureHandler = (request: TeacherBoardImageRenderRequest) => Promise @@ -529,18 +529,6 @@ function agentSystemPrompt(level: CoachUserLevel): string { return systemPrompt(level) } -function providerSettingsFromApp(): ProviderSettings { - const settings = getSettings() - if (!settings.llmBaseUrl.trim() || !settings.llmApiKey.trim() || !settings.llmModel.trim()) { - throw new Error('请先配置支持 tool calling 和图片输入的 OpenAI-compatible LLM 代理。') - } - return { - llmBaseUrl: settings.llmBaseUrl, - llmApiKey: settings.llmApiKey, - llmModel: settings.llmModel - } -} - function stringInput(input: JsonObject, key: string, fallback = ''): string { const value = input[key] return typeof value === 'string' ? value.trim() : fallback @@ -2002,6 +1990,54 @@ async function executeAgentToolCall( } } +async function prefetchEvidenceForManagedProvider( + state: TeacherAgentSessionState, + tools: Map +): Promise { + const calls: Array<{ name: string; arguments: JsonObject }> = [] + const common = { gameId: state.request.gameId, moveNumber: state.request.moveNumber } + if (state.intent === 'current-move') { + calls.push( + { name: 'katago_analyzePosition', arguments: common }, + { name: 'board_captureTeachingImage', arguments: { ...common, selection: 'current', maxImages: 1 } }, + { name: 'knowledge_matchPosition', arguments: { text: state.request.prompt, moveNumber: state.request.moveNumber, maxResults: 6 } } + ) + } else if (state.intent === 'move-range') { + calls.push( + { name: 'katago_analyzeMoveRangeKeyMoves', arguments: {} }, + { name: 'board_captureTeachingImage', arguments: { gameId: state.request.gameId, selection: 'move-range-top-loss', maxImages: 6 } }, + { name: 'knowledge_matchPosition', arguments: { text: state.request.prompt, maxResults: 6 } } + ) + } else if (state.intent === 'game-review') { + calls.push( + { name: 'katago_analyzeGameBatch', arguments: { gameId: state.request.gameId, count: 1, maxVisits: 24, minWinrateDrop: 4 } }, + { name: 'board_captureTeachingImage', arguments: { gameId: state.request.gameId, selection: 'top-loss', maxImages: 6 } }, + { name: 'knowledge_searchLocal', arguments: { text: state.request.prompt, maxResults: 6 } } + ) + } else if (state.intent === 'batch-review') { + calls.push( + { name: 'library_findGames', arguments: { studentName: state.studentName, count: inferCount(state.request.prompt) } }, + { name: 'katago_analyzeGameBatch', arguments: { studentName: state.studentName, count: inferCount(state.request.prompt), maxVisits: 24, minWinrateDrop: 6 } }, + { name: 'knowledge_searchLocal', arguments: { text: state.request.prompt, maxResults: 6 } } + ) + } else { + calls.push({ name: 'knowledge_searchLocal', arguments: { text: state.request.prompt, maxResults: 6 } }) + } + + const messages: ChatMessage[] = [] + for (let index = 0; index < calls.length; index += 1) { + const item = calls[index] + const result = await executeAgentToolCall({ + id: `prefetch-${index + 1}`, + type: 'function', + function: { name: item.name, arguments: JSON.stringify(item.arguments) } + }, tools, state) + messages.push({ role: 'user', content: `GoAgent 本地证据(${item.name}):\n${result.toolResult}` }) + messages.push(...result.followupMessages) + } + return messages +} + async function runTeacherAgentSession( request: TeacherRunRequest, logs: TeacherToolLog[], @@ -2042,13 +2078,16 @@ async function runTeacherAgentSession( state.teachingPacing = buildTeachingPacingAdvice(request.prefetchedAnalysis) } - const settings = providerSettingsFromApp() + const settings = getSettings() const toolDefinitions = createTeacherAgentTools(state) const toolMap = new Map(toolDefinitions.map((tool) => [tool.apiName, tool])) const tools = toolDefinitions.map(chatTool) + const providerSupportsTools = activeProviderSupportsTools(settings) + const prefetchedMessages = providerSupportsTools ? [] : await prefetchEvidenceForManagedProvider(state, toolMap) const messages: ChatMessage[] = [ { role: 'system', content: agentSystemPrompt(profile.userLevel) }, - initialAgentUserMessage(state) + initialAgentUserMessage(state), + ...prefetchedMessages ] emitProgress(context, { stage: 'assistant-start', message: 'GoAgent agent 开始推理。', toolLogs: cloneToolLogs(logs) }) @@ -2059,7 +2098,7 @@ async function runTeacherAgentSession( let streamedThisTurn = '' let result: ChatTurnResult try { - result = await streamOpenAICompatibleToolTurn(settings, messages, tools, 4096, (delta) => { + result = await runProviderTurn(settings, messages, providerSupportsTools ? tools : [], 4096, (delta) => { streamedThisTurn += delta emittedText += delta emitAssistantDelta(context, delta) @@ -2109,7 +2148,7 @@ async function runTeacherAgentSession( messages.push({ role: 'user', content: `${buildVisionEvidenceRepairNote(visionIssues)}\n\n${formatVisionEvidenceForPrompt(finalVisionEvidence)}` }) let repair: ChatTurnResult try { - repair = await streamOpenAICompatibleToolTurn(settings, messages, tools, 2048, (delta) => { + repair = await runProviderTurn(settings, messages, providerSupportsTools ? tools : [], 2048, (delta) => { emitAssistantDelta(context, delta) }, context?.signal) } catch (error) { diff --git a/src/main/services/teacherSession.ts b/src/main/services/teacherSession.ts index 0255642..43bd73c 100644 --- a/src/main/services/teacherSession.ts +++ b/src/main/services/teacherSession.ts @@ -30,6 +30,13 @@ function hasVisibleTeacherMessages(session: TeacherSession): boolean { ) } +function hasObsoleteCodexReadAccessFailure(session: TeacherSession): boolean { + return session.messages.some((message) => + message.status === 'error' && + message.content.includes('readOnly.access is no longer supported; use permissionProfile for restricted reads') + ) +} + function materializeSession(input: Partial = {}, timestamp = nowIso()): TeacherSession { return { id: input.id || randomUUID(), @@ -68,7 +75,17 @@ export function listTeacherSessions(includeArchived = true): TeacherSession[] { export function getActiveTeacherSession(): TeacherSession { const activeId = teacherSessionStore.get('activeSessionId', '') const existing = readSessions().find((session) => session.id === activeId && !session.archivedAt) - if (existing) return existing + if (existing && !hasObsoleteCodexReadAccessFailure(existing)) return existing + if (existing) { + // Keep the failed attempt in history, but do not present a protocol error + // from an older GoAgent build as the current teacher state after upgrade. + return createTeacherSession({ + gameId: existing.gameId, + moveNumber: existing.moveNumber, + moveRange: existing.moveRange, + studentId: existing.studentId + }) + } return createTeacherSession() } diff --git a/src/preload/index.ts b/src/preload/index.ts index 37ac23f..491cd72 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -31,9 +31,8 @@ import type { LlmModelsListResult, LlmSettingsTestRequest, LlmSettingsTestResult, + LlmConnectionActionResult, KataGoMoveAnalysis, - ReviewRequest, - ReviewResult, StudentBindingSuggestion, StudentProfile, ReleaseReadinessResult, @@ -86,7 +85,6 @@ const api = { updateSettings: (payload: Partial): Promise => ipcRenderer.invoke('settings:update', payload), autoDetectSettings: (): Promise => ipcRenderer.invoke('settings:auto-detect'), syncFox: (payload: FoxSyncRequest): Promise => ipcRenderer.invoke('fox:sync', payload), - startReview: (payload: ReviewRequest): Promise => ipcRenderer.invoke('review:start', payload), analyzePosition: (payload: AnalyzePositionRequest): Promise => ipcRenderer.invoke('katago:analyze-position', payload), analyzePositionStream: (payload: AnalyzePositionRequest): Promise => ipcRenderer.invoke('katago:analyze-position-stream', payload), analyzeTrialPositionStream: (payload: AnalyzeTrialPositionRequest): Promise => ipcRenderer.invoke('katago:analyze-trial-position-stream', payload), @@ -165,6 +163,8 @@ const api = { }, testLlmSettings: (payload: LlmSettingsTestRequest): Promise => ipcRenderer.invoke('llm:test', payload), listLlmModels: (payload: LlmModelsListRequest): Promise => ipcRenderer.invoke('llm:list-models', payload), + startChatGptLogin: (payload?: { useDeviceCode?: boolean }): Promise => ipcRenderer.invoke('llm:chatgpt-login', payload), + logoutChatGpt: (): Promise => ipcRenderer.invoke('llm:chatgpt-logout'), getSavedLlmApiKey: (): Promise<{ hasKey: boolean; apiKey: string }> => ipcRenderer.invoke('llm:get-saved-api-key'), getSavedIkatagoPassword: (): Promise<{ hasPassword: boolean; password: string }> => ipcRenderer.invoke('ikatago:get-saved-password'), loginZhiziCloudPassword: (payload: ZhiziCloudLoginRequest): Promise => ipcRenderer.invoke('zhizi:login-password', payload), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f5eafb0..f323bb7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -118,7 +118,13 @@ const emptyDashboard: DashboardData = { pythonBin: 'python', llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', - llmModel: 'gpt-5-mini', + llmModel: 'gpt-5.6-sol', + activeLlmConnectionId: 'openai-compatible-default', + llmConnections: [ + { id: 'openai-compatible-default', name: 'OpenAI-compatible API', provider: 'openai-compatible', authMode: 'api-key', endpoint: 'https://api.openai.com/v1', model: 'gpt-5.6-sol', enabled: true }, + { id: 'chatgpt-codex', name: 'ChatGPT 登录', provider: 'codex-app-server', authMode: 'managed-login', model: '', enabled: true } + ], + llmConnectionSchemaVersion: 2, onboardingVersion: 0, llmSetupStatus: 'unconfigured', llmLastVerifiedAt: '', @@ -175,6 +181,14 @@ const emptyDashboard: DashboardData = { proxyApiKey: '', proxyModels: [], hasLlmApiKey: false, + llmConnection: { + connectionId: 'openai-compatible-default', + provider: 'openai-compatible', + authMode: 'api-key', + ready: false, + status: 'signed-out', + message: '尚未配置 LLM。' + }, hasZhiziToken: false, notes: [] } @@ -1640,7 +1654,8 @@ export function App(): ReactElement { const result = await window.goagent.testLlmSettings({ llmBaseUrl: String(formData.get('llmBaseUrl') ?? ''), llmApiKey: String(formData.get('llmApiKey') ?? ''), - llmModel: String(formData.get('llmModel') ?? '') + llmModel: String(formData.get('llmModel') ?? ''), + connectionId: dashboard.settings.activeLlmConnectionId }) setLlmTestMessage(result.ok ? `${t('settingsAiTitle')} · ${t('ready')}` : t('llmSetupRequired')) setDashboard(await window.goagent.getDashboard()) @@ -2737,7 +2752,7 @@ export function App(): ReactElement { } function ensureAiTeacherReady(): boolean { - const ready = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' + const ready = dashboard.systemProfile.llmConnection.ready if (ready) return true setLlmTestMessage(t('llmSetupRequired')) setSettingsOpen(true) @@ -3021,7 +3036,7 @@ export function App(): ReactElement { await submitTeacherPromptText(prompt) } - const llmReady = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' + const llmReady = dashboard.systemProfile.llmConnection.ready const statusItems: StatusPill[] = [ { label: localizeKataGoStatus( @@ -3615,7 +3630,7 @@ function DesktopPreferencesModal({ return null } const katagoReady = katagoAssets?.ready || dashboard.systemProfile.katagoReady - const llmReady = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' + const llmReady = dashboard.systemProfile.llmConnection.ready return (
event.stopPropagation()}> @@ -4594,6 +4609,7 @@ function TeacherPanel({ onChange={onPrompt} onSubmit={onSubmit} onStop={onStop} + onExplainCurrentMove={onAnalyze} t={t} />
@@ -4675,6 +4691,9 @@ function SettingsDrawer({ const [selectedPresetId, setSelectedPresetId] = useState(dashboard.settings.katagoModelPreset) const selectedPreset = modelPresets.find((preset) => preset.id === selectedPresetId) ?? modelPresets[0] const localeOptions = SUPPORTED_UI_LOCALES + const activeLlmConnection = dashboard.settings.llmConnections.find((connection) => connection.id === dashboard.settings.activeLlmConnectionId) + ?? dashboard.settings.llmConnections[0] + const managedLlmLogin = activeLlmConnection?.provider === 'codex-app-server' const llmModelOptions = useMemo(() => { if (llmModelsFetched) { return refreshedLlmModels @@ -4741,7 +4760,8 @@ function SettingsDrawer({ try { const result = await window.goagent.listLlmModels({ llmBaseUrl: dashboard.settings.llmBaseUrl, - llmApiKey: '' + llmApiKey: '', + connectionId: dashboard.settings.activeLlmConnectionId }) if (result.ok) { const models = uniqueModelOptions(result.models) @@ -4749,10 +4769,10 @@ function SettingsDrawer({ setLlmModelsFetched(true) if (!models.length) { setLlmModelRefreshMessage(`${t('noModelReturned')}。${t('modelPickerEmpty')}`) - } else if (!models.includes(selectedLlmModel)) { - const fallback = models.includes(dashboard.settings.llmModel) ? dashboard.settings.llmModel : models[0] + } else if (!models.includes(selectedLlmModel) || selectedLlmModel === 'gpt-5-mini') { + const fallback = result.recommendedModel || (models.includes(dashboard.settings.llmModel) ? dashboard.settings.llmModel : models[0]) setSelectedLlmModel(fallback) - autoSave({ llmModel: fallback }, 0) + saveLlmModel(fallback) } } if (result.models.length) { @@ -4763,7 +4783,7 @@ function SettingsDrawer({ } finally { setLlmModelsRefreshing(false) } - }, [dashboard.settings.llmBaseUrl, dashboard.settings.llmModel, selectedLlmModel, autoSave, t]) + }, [dashboard.settings.activeLlmConnectionId, dashboard.settings.llmBaseUrl, dashboard.settings.llmModel, selectedLlmModel, autoSave, t]) useEffect(() => { setSelectedPresetId(dashboard.settings.katagoModelPreset) @@ -4775,8 +4795,8 @@ function SettingsDrawer({ const llmAutoFetchKeyRef = useRef('') useEffect(() => { - const fetchKey = `${dashboard.settings.llmBaseUrl}|${dashboard.systemProfile.hasLlmApiKey ? '1' : '0'}` - if (!dashboard.settings.llmBaseUrl.trim() || !dashboard.systemProfile.hasLlmApiKey) { + const fetchKey = `${dashboard.settings.activeLlmConnectionId}|${dashboard.settings.llmBaseUrl}|${dashboard.systemProfile.llmConnection.ready ? '1' : '0'}` + if (!dashboard.systemProfile.llmConnection.ready) { return } if (llmAutoFetchKeyRef.current === fetchKey) { @@ -4787,7 +4807,56 @@ function SettingsDrawer({ void refreshLlmModels() }, 600) return () => clearTimeout(timer) - }, [dashboard.settings.llmBaseUrl, dashboard.systemProfile.hasLlmApiKey, refreshLlmModels]) + }, [dashboard.settings.activeLlmConnectionId, dashboard.settings.llmBaseUrl, dashboard.systemProfile.llmConnection.ready, refreshLlmModels]) + + useEffect(() => { + if (!managedLlmLogin || dashboard.systemProfile.llmConnection.ready) return + const timer = setInterval(() => { + void window.goagent.getDashboard().then((updated) => { + onDashboardUpdated(updated) + if (updated.systemProfile.llmConnection.ready) void refreshLlmModels() + }).catch(() => undefined) + }, 2000) + return () => clearInterval(timer) + }, [managedLlmLogin, dashboard.systemProfile.llmConnection.ready, onDashboardUpdated, refreshLlmModels]) + + function saveLlmModel(model: string): void { + if (!managedLlmLogin) { + autoSave({ llmModel: model }, 0) + return + } + autoSave({ + llmConnections: dashboard.settings.llmConnections.map((connection) => + connection.id === dashboard.settings.activeLlmConnectionId ? { ...connection, model } : connection + ) + }, 0) + } + + async function selectLlmProvider(connectionId: string): Promise { + const updated = await window.goagent.updateSettings({ activeLlmConnectionId: connectionId }) + setLlmModelsFetched(false) + setRefreshedLlmModels([]) + setSelectedLlmModel(updated.settings.llmModel) + onDashboardUpdated(updated) + } + + async function loginWithChatGpt(): Promise { + setLlmModelRefreshMessage('正在检查本机 Codex 登录…') + try { + const result = await window.goagent.startChatGptLogin() + onDashboardUpdated(result.dashboard) + setLlmModelRefreshMessage(result.login ? '请在浏览器完成登录;完成后这里会自动更新。' : '已复用 Codex 的 ChatGPT 登录。') + } catch (cause) { + setLlmModelRefreshMessage(String(cause)) + } + } + + async function logoutFromChatGpt(): Promise { + const result = await window.goagent.logoutChatGpt() + onDashboardUpdated(result.dashboard) + setLlmModelsFetched(false) + setRefreshedLlmModels([]) + } async function revealSavedLlmApiKey(): Promise { setLlmKeyMessage('') @@ -4814,7 +4883,7 @@ function SettingsDrawer({ const zhiziEnabled = dashboard.settings.katagoEngineMode === 'zhizi' const zhiziLoggedIn = dashboard.systemProfile.hasZhiziToken const zhiziNav = zhiziSettingsNavCopy(dashboard.settings.reviewLanguage) - const llmReady = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' + const llmReady = dashboard.systemProfile.llmConnection.ready const katagoReady = Boolean(katagoAssets?.ready || dashboard.systemProfile.katagoReady) const voiceReady = dashboard.settings.ttsEnabled const settingsPages: Array<{ @@ -4924,6 +4993,23 @@ function SettingsDrawer({ {llmReady ? t('ready') : t('pendingConfig')} +
+ + +
+ {!managedLlmLogin ? <>