diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e890ab..62f8a41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate Codex App Server manifest + run: pnpm check:codex-runtime + - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/p0-release-candidate.yml b/.github/workflows/p0-release-candidate.yml index db8258c..e29f405 100644 --- a/.github/workflows/p0-release-candidate.yml +++ b/.github/workflows/p0-release-candidate.yml @@ -52,6 +52,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate Codex App Server manifest + run: pnpm check:codex-runtime + - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc8877b..37a6b47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -198,6 +198,17 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Prepare embedded Codex App Server + shell: bash + run: | + if [[ "${RUNNER_OS}" == "macOS" ]]; then + pnpm prepare:codex-runtime:mac + node scripts/check_codex_runtime.mjs --mode=release --platform=darwin-arm64 --platform=darwin-x64 + else + pnpm prepare:codex-runtime:win + node scripts/check_codex_runtime.mjs --mode=release --platform=win32-x64 + fi + - name: Download Kokoro TTS assets uses: actions/download-artifact@v4 with: @@ -445,6 +456,15 @@ jobs: hdiutil verify "$dmg" done + - name: Verify packaged Codex App Server + shell: bash + run: | + if [[ "${RUNNER_OS}" == "macOS" ]]; then + node scripts/smoke_codex_packaged_runtime.mjs --root=release --expect=darwin-arm64 --expect=darwin-x64 + else + node scripts/smoke_codex_packaged_runtime.mjs --root=release --expect=win32-x64 + fi + - name: Smoke packaged Windows app if: runner.os == 'Windows' run: node scripts/smoke_windows_packaged_app.mjs --mode=full --require-katago @@ -500,6 +520,11 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Prepare embedded Codex App Server + run: | + pnpm prepare:codex-runtime:win + node scripts/check_codex_runtime.mjs --mode=release --platform=win32-x64 + - name: Download Kokoro TTS assets uses: actions/download-artifact@v4 with: @@ -622,6 +647,9 @@ jobs: throw "NVIDIA portable archive exceeds GitHub's 2 GiB asset limit: $($nvidiaPortable.Name) $($nvidiaPortable.Length)" } + - name: Verify packaged Codex App Server + run: node scripts/smoke_codex_packaged_runtime.mjs --root=release --expect=win32-x64 + - name: Smoke NVIDIA packaged Windows app run: node scripts/smoke_windows_packaged_app.mjs --mode=nvidia --require-katago diff --git a/.gitignore b/.gitignore index f684783..d040461 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ data/katago/models/** data/katago/edition.json !data/katago/README.md +# Platform Codex App Server binaries are verified release inputs, not source files. +data/codex/bin/** + # Large local Kokoro TTS model files are prepared by scripts for release packaging. # Voices and metadata are small enough to version; the ONNX model is not. data/tts/kokoro/**/onnx/*.onnx diff --git a/README.md b/README.md index 89bd991..9c8ecec 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ docs 架构、发布、签名、公证、QA 文档 - pnpm 10+ - Python 3.10+ - KataGo 二进制和一个 KataGo 模型 -- 可选:OpenAI-compatible 多模态 LLM API +- 可选:OpenAI-compatible 多模态 LLM API,或通过官方 Codex App Server 使用 ChatGPT 登录 启动: @@ -196,6 +196,12 @@ pnpm dist:win pnpm dist:linux ``` +## AI 老师连接 + +- **API Key**:可继续使用支持 OpenAI-compatible API 的多模态模型服务。 +- **ChatGPT 登录**:在“设置 → AI 老师”选择“使用 ChatGPT 登录”。GoAgent 通过官方 Codex App Server 完成登录、模型发现和请求;可使用当前 ChatGPT 套餐中支持棋盘图片输入的模型。 +- 登录型连接使用 GoAgent 独立的 Codex App Server 数据目录,不读取或修改系统 Codex CLI / Codex Desktop 的登录;GoAgent 业务代码不读取、复制或输出 OAuth token。 + ## KataGo 资源 GoAgent 优先寻找随安装包携带的 KataGo 运行时: @@ -214,8 +220,8 @@ data/katago/ ## 隐私与安全 - 棋谱、学生画像、报告和设置默认保存在 `~/.goagent`。 -- LLM API Key 在支持的平台上使用 Electron `safeStorage` 加密保存。 -- 前端不会拿到已保存的完整 API Key。 +- LLM API Key 保存在 GoAgent 本地 secret store 中;只有用户主动点击“显示 Key”核对时才会在设置页读取并显示。 +- ChatGPT 登录凭据由 GoAgent 内置的官方 Codex App Server 保存在 GoAgent 专属目录中;GoAgent 业务代码不会读取或输出 OAuth token,也不会触碰系统 Codex 登录。 - 当前手讲解会发送棋盘截图、KataGo JSON 和知识库摘录到用户配置的 LLM 服务。 - Web 搜索只用于泛化围棋概念,不发送学生姓名、棋谱原文、截图、API Key 或本机路径。 diff --git a/README_EN.md b/README_EN.md index 4193739..bf95ec8 100644 --- a/README_EN.md +++ b/README_EN.md @@ -124,7 +124,7 @@ Requirements: - 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. @@ -151,10 +151,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. +- ChatGPT sign-in uses a GoAgent-specific Codex App Server data directory. It does not read or modify Codex CLI or Codex Desktop sign-in. GoAgent application code does not read, copy, or print OAuth 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 and are only read back into Settings when the user explicitly chooses “Show key.” +- The embedded official Codex App Server keeps ChatGPT credentials in GoAgent's dedicated data directory. GoAgent application code does not read or print OAuth tokens and does not touch the system Codex sign-in. - 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/build/afterPack.cjs b/build/afterPack.cjs new file mode 100644 index 0000000..f812ccf --- /dev/null +++ b/build/afterPack.cjs @@ -0,0 +1,32 @@ +const { createHash } = require('node:crypto') +const { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync } = require('node:fs') +const { join } = require('node:path') +const { Arch } = require('builder-util') + +module.exports = async function afterPack(context) { + const projectDir = context.packager.projectDir + const manifestPath = join(projectDir, 'data', 'codex', 'manifest.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + const arch = Arch[context.arch] + const target = `${context.electronPlatformName}-${arch}` + const asset = manifest.targets[target] + if (!asset) throw new Error(`Codex runtime manifest does not support ${target}`) + + const source = join(projectDir, 'data', 'codex', 'bin', target, asset.executable) + if (!existsSync(source)) throw new Error(`Codex runtime is missing for ${target}. Run the matching prepare:codex-runtime script first.`) + const digest = createHash('sha256').update(readFileSync(source)).digest('hex') + if (statSync(source).size !== asset.bytes || digest !== asset.sha256) { + throw new Error(`Codex runtime validation failed for ${target}`) + } + + const resources = context.electronPlatformName === 'darwin' + ? join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources') + : join(context.appOutDir, 'resources') + const destinationDir = join(resources, 'data', 'codex') + const destination = join(destinationDir, 'bin', target, asset.executable) + mkdirSync(join(destinationDir, 'bin', target), { recursive: true }) + copyFileSync(source, destination) + copyFileSync(manifestPath, join(destinationDir, 'manifest.json')) + copyFileSync(join(projectDir, 'data', 'codex', 'LICENSE'), join(destinationDir, 'LICENSE')) + if (context.electronPlatformName !== 'win32') chmodSync(destination, 0o755) +} diff --git a/data/codex/LICENSE b/data/codex/LICENSE new file mode 100644 index 0000000..4606e72 --- /dev/null +++ b/data/codex/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 OpenAI + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/data/codex/manifest.json b/data/codex/manifest.json new file mode 100644 index 0000000..d3c607a --- /dev/null +++ b/data/codex/manifest.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "runtime": "codex-app-server", + "version": "0.149.0", + "source": "https://www.npmjs.com/package/@openai/codex", + "license": "Apache-2.0", + "targets": { + "darwin-arm64": { + "url": "https://registry.npmjs.org/@openai/codex/-/codex-0.149.0-darwin-arm64.tgz", + "archivePath": "package/vendor/aarch64-apple-darwin/bin/codex", + "executable": "codex", + "bytes": 220538240, + "sha256": "f4a74117b8142cda581c95ff753abf4508b5636d89682c1ed77e4a9249af8963" + }, + "darwin-x64": { + "url": "https://registry.npmjs.org/@openai/codex/-/codex-0.149.0-darwin-x64.tgz", + "archivePath": "package/vendor/x86_64-apple-darwin/bin/codex", + "executable": "codex", + "bytes": 237836624, + "sha256": "c646bd178240bb50efd81c2f9919dd9124b126c815911f6c1b6db400786c5ccd" + }, + "win32-x64": { + "url": "https://registry.npmjs.org/@openai/codex/-/codex-0.149.0-win32-x64.tgz", + "archivePath": "package/vendor/x86_64-pc-windows-msvc/bin/codex.exe", + "executable": "codex.exe", + "bytes": 297362224, + "sha256": "14b7e6b2356e82d1d9275579eaa588757b4e0a501b65dcc19fccdf77bd83dc00" + }, + "linux-x64": { + "url": "https://registry.npmjs.org/@openai/codex/-/codex-0.149.0-linux-x64.tgz", + "archivePath": "package/vendor/x86_64-unknown-linux-musl/bin/codex", + "executable": "codex", + "bytes": 258322048, + "sha256": "bbc3341e44c9ead340ed9570c17be936e37870f570751a941699ffd04d672827" + }, + "linux-arm64": { + "url": "https://registry.npmjs.org/@openai/codex/-/codex-0.149.0-linux-arm64.tgz", + "archivePath": "package/vendor/aarch64-unknown-linux-musl/bin/codex", + "executable": "codex", + "bytes": 222824000, + "sha256": "1ee36d2ccaf0c4f3f1d3ce8db894739a07928cfd75ded684e84e9a19b9c436c7" + } + } +} diff --git a/docs/CODEX_APP_SERVER.md b/docs/CODEX_APP_SERVER.md new file mode 100644 index 0000000..a9ebc1d --- /dev/null +++ b/docs/CODEX_APP_SERVER.md @@ -0,0 +1,35 @@ +# ChatGPT login through Codex App Server + +GoAgent keeps its domain-specific teacher runtime and optionally uses Codex App Server as a ChatGPT account connection. Codex does not replace GoAgent's KataGo, board capture, knowledge, student profile, evidence, or teaching-quality logic. + +## Runtime boundary + +- Version is pinned in `data/codex/manifest.json` and `package.json`. +- Login data is stored below GoAgent's own application directory in `codex/`; it does not read or change the user's global Codex CLI or Codex Desktop login. +- Only GoAgent-provided dynamic tools are exposed to the model. Built-in command, file-editing, web, app, plugin, browser, computer, memory, and multi-agent features are disabled. +- The App Server runs in a temporary read-only working directory with network disabled. A request to use a non-GoAgent built-in tool interrupts the turn. +- Selecting ChatGPT never falls back to an API-key provider. Failures are shown for the selected connection. + +## Preparing release assets + +The native runtimes are downloaded from the official `@openai/codex` npm package and verified by exact size and SHA256. They are generated release inputs and are not committed to Git. + +```bash +pnpm prepare:codex-runtime:mac +pnpm prepare:codex-runtime:win +pnpm check:codex-runtime +``` + +`build/afterPack.cjs` copies only the target architecture into the Electron resources directory. macOS signing and notarization therefore include the embedded executable. Release smoke testing must run the packaged binary with `--version` on each target platform. + +The manifest SHA256 identifies the official unsigned source asset. Platform code signing legitimately changes executable bytes. The package smoke therefore requires either an exact source hash or valid platform-signing evidence, and always starts the native runtime on the matching build host to confirm the pinned version. + +## Capability verification + +Login alone is not a capability result. GoAgent's connection test performs three real turns: + +1. deterministic text response; +2. visual recognition of a generated test image; +3. an actual experimental `dynamicTools` call handled by GoAgent. + +All three must pass before `llmSetupStatus` becomes `verified`. diff --git a/docs/MULTI_PROVIDER_MODEL_ACCESS.md b/docs/MULTI_PROVIDER_MODEL_ACCESS.md new file mode 100644 index 0000000..8668d29 --- /dev/null +++ b/docs/MULTI_PROVIDER_MODEL_ACCESS.md @@ -0,0 +1,85 @@ +# AI 老师连接架构 + +GoAgent 保留自己的围棋 Agent Runtime,并提供两种互不回退的连接方式: + +- **OpenAI-compatible API**:现有稳定路径,继续使用 Base URL、API Key 和模型名。 +- **ChatGPT 登录**:可选路径,由 GoAgent 内置的 Codex App Server 完成登录、模型请求和流式响应。 + +Codex App Server 是连接适配器,不替换棋盘截图、KataGo、知识库、学生画像、证据门禁或老师会话。 + +## 设计原则 + +1. 现有 API 用户的默认模型、配置和工具循环保持不变。 +2. 用户选择哪种连接,本轮任务就只使用哪种连接;失败时明确报错,不自动切换。 +3. 两种连接都使用 GoAgent 的同一套围棋工具和证据规则。 +4. ChatGPT 登录使用 GoAgent 专属 `CODEX_HOME`,不读取或修改系统 Codex CLI / Codex Desktop 登录。 +5. 登录成功不等于能力可用。文字、图片和动态工具三项真实测试全部通过后,AI 老师才标记为就绪。 + +## 运行时边界 + +```text +TeacherAgentRuntime + ├─ OpenAICompatibleAdapter + └─ CodexAppServerAdapter + └─ experimental dynamicTools + +GoAgent-owned domain services + ├─ board.captureTeachingImage + ├─ katago.* + ├─ sgf.readGameRecord / library.findGames + ├─ knowledge.* + ├─ studentProfile.* + └─ report / teaching artifact +``` + +OpenAI-compatible 连接保留现有完整工具能力。Codex 连接只暴露上图中的围棋领域工具,不暴露 GoAgent 的 shell、文件系统、设置写入或联网搜索工具。App Server 自带的命令、文件修改、网页、浏览器、插件、应用和多代理能力也会被关闭;如果服务端仍请求这些能力,本轮任务会被中断。 + +App Server 在临时只读目录中运行,且 GoAgent 请求关闭网络访问。由于 `dynamicTools` 仍是实验接口,Codex 路径必须通过契约测试和真实安装包验收后才能发布。 + +## Tool-first 讲棋 + +ChatGPT 路径不预先把固定材料一次性塞给模型。模型根据任务自主调用同一套围棋工具: + +- 当前手:棋盘截图、当前局面 KataGo、知识匹配。 +- 整盘:读取棋谱、批量 KataGo、关键手截图、知识匹配。 +- 区间:区间关键手分析、关键手截图、知识匹配。 + +最终回答仍由 GoAgent 校验证据。缺少必需截图、KataGo 或知识证据时,运行时明确失败,不生成伪讲解。 + +## 登录与隐私 + +ChatGPT 登录状态由 GoAgent 内置的官方 Codex App Server 保存到 GoAgent 应用目录下的 `codex/`。GoAgent 业务代码不读取、复制、打印或返回 OAuth token。退出登录只影响这个专属目录,不影响用户安装的 Codex CLI 或 Codex Desktop。 + +棋盘图片、KataGo 证据、知识摘录和用户消息会发送给当前主动选择的 AI 服务。renderer 不接收 OAuth token;已有 API Key 仅在用户主动点击“显示密钥”时读取到设置页,正常讲棋流程不会回传完整密钥。 + +## 内置运行时 + +首版固定 Codex `0.149.0`。平台二进制来自官方 `@openai/codex` npm 发布包,下载后按 `data/codex/manifest.json` 的字节数和 SHA256 校验。大文件不进入 Git;打包时 `build/afterPack.cjs` 只复制目标平台的二进制到安装包资源目录,使其进入后续 macOS 签名和公证流程。 + +```bash +pnpm prepare:codex-runtime:mac +pnpm prepare:codex-runtime:win +pnpm check:codex-runtime +``` + +安装包不能依赖系统 PATH 中的 `codex`。在未安装全局 Codex 的干净机器上,ChatGPT 登录、图片输入和围棋工具调用都必须可用。 + +## 发布门禁 + +- 现有 OpenAI-compatible 用户升级后配置与默认模型不变。 +- ChatGPT 文字、测试图片、真实动态工具探测全部通过。 +- 当前手、整盘和区间任务满足各自证据门禁。 +- 取消任务能中断当前 Codex turn。 +- 退出 GoAgent ChatGPT 登录不影响系统 Codex 登录。 +- macOS arm64/x64 与 Windows x64 安装包内的二进制版本、大小和 SHA256 正确。 +- `pnpm test`、`pnpm typecheck`、`pnpm build`、`pnpm check`、`pnpm check:teacher-quality` 全部通过。 + +## 上游稳定性说明 + +Codex App Server 适合嵌入桌面产品,但 `dynamicTools` 目前仍是实验接口。GoAgent 会固定已验证版本,并在升级前重复协议、真实账号和安装包测试。只有动态工具稳定、能力严格可控且实际维护收益明确时,才重新评估是否扩大 Codex 在默认运行时中的职责。 + +参考: + +- [Codex App Server](https://developers.openai.com/codex/app-server) +- [Codex authentication](https://developers.openai.com/codex/auth) +- [OpenAI Codex releases](https://github.com/openai/codex/releases) diff --git a/package.json b/package.json index 00d80d3..e52510d 100644 --- a/package.json +++ b/package.json @@ -58,11 +58,18 @@ "dist:mac": "pnpm build && electron-builder --mac --publish never", "dist:win": "pnpm build && electron-builder --win --publish never", "dist:linux": "pnpm build && electron-builder --linux --publish never", - "dist:local:mac": "pnpm prepare:katago-transformer && pnpm dist:mac", - "dist:local:win": "pnpm prepare:katago-transformer && pnpm dist:win", - "dist:local:linux": "pnpm prepare:katago-transformer && pnpm dist:linux", + "dist:local:mac": "pnpm prepare:katago-transformer && pnpm prepare:codex-runtime:mac && pnpm dist:mac", + "dist:local:win": "pnpm prepare:katago-transformer && pnpm prepare:codex-runtime:win && pnpm dist:win", + "dist:local:linux": "pnpm prepare:katago-transformer && pnpm prepare:codex-runtime:linux && pnpm dist:linux", "postinstall": "electron-builder install-app-deps", "prepare:python": "python3 -m pip install -r scripts/requirements.txt", + "prepare:codex-runtime": "node scripts/prepare_codex_runtime.mjs", + "prepare:codex-runtime:mac": "node scripts/prepare_codex_runtime.mjs --platform=darwin-arm64 --platform=darwin-x64", + "prepare:codex-runtime:win": "node scripts/prepare_codex_runtime.mjs --platform=win32-x64", + "prepare:codex-runtime:linux": "node scripts/prepare_codex_runtime.mjs", + "check:codex-runtime": "node scripts/check_codex_runtime.mjs --mode=dev", + "check:codex-runtime:release": "node scripts/check_codex_runtime.mjs --mode=release", + "smoke:codex-runtime": "node scripts/smoke_codex_packaged_runtime.mjs", "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", @@ -93,6 +100,7 @@ "zod": "^4.1.5" }, "devDependencies": { + "@openai/codex": "0.149.0", "@types/node": "^24.5.2", "@types/qrcode": "^1.5.6", "@types/react": "^19.1.13", @@ -117,6 +125,7 @@ }, "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "afterSign": "build/afterSign.cjs", + "afterPack": "build/afterPack.cjs", "files": [ "out/**/*", "scripts/**/*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59b9626..4fc0483 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: specifier: ^4.1.5 version: 4.3.6 devDependencies: + '@openai/codex': + specifier: 0.149.0 + version: 0.149.0 '@types/node': specifier: ^24.5.2 version: 24.12.0 @@ -1014,6 +1017,47 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@openai/codex@0.149.0': + resolution: {integrity: sha512-i4dryj2Y1j+00Mb5n+0n71EYnTK9/KDc2cdFo/dXD0d1oTog2bhUssKDEIOnKmnEf51P0Z/HJTWvTKw/UHyOvQ==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.149.0-darwin-arm64': + resolution: {integrity: sha512-GsZJbzBWiD48RETrO8VHGAQNgfSrUVxItXZFeD87wswatPi0+lKuQo8Dx4nMYmOZhZrVtwr3al/feRrZxnDV8Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.149.0-darwin-x64': + resolution: {integrity: sha512-H+mMgW3Nhc5QzGWEklCoFqACuOc0cVpgPkPQRw0LShoK7P5664T6BRnyl1yzT6orKPKv49cXry7DIWWZ19SanQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.149.0-linux-arm64': + resolution: {integrity: sha512-fAXPpvIob+11RNZJS9CVVTsKb+V4Hw3woGFPj42D7fU2wBJUKI2jfAc4fLJNtrpwRecLeW601mtkMHOSIbWuuA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.149.0-linux-x64': + resolution: {integrity: sha512-uZXaN9JPxu0/jjnqqJeTd4kRYPnjVZK3MiVndfG1mHhEaoDKL7ScWHfPqvAEOjwsSDEmQSlMfUkmvYp/CHciYw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.149.0-win32-arm64': + resolution: {integrity: sha512-pUd8MzuwtqT5DhM1NUE1gETWIZ9fkDA1XB7tt9YNIi/peUgLuziQgZd7o0bNON4cNzgbil1YUN1qDTgQm0g3pg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.149.0-win32-x64': + resolution: {integrity: sha512-qKbwSOOO/fdhQ5MlXE2fts6taPxRPZ/zqeC+eqHD72hLRymV9rFCUbUxOCquognUPRPvS/2/kRCV0UVhoDd3yQ==} + 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.149.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.149.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.149.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.149.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.149.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.149.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.149.0-win32-x64' + + '@openai/codex@0.149.0-darwin-arm64': + optional: true + + '@openai/codex@0.149.0-darwin-x64': + optional: true + + '@openai/codex@0.149.0-linux-arm64': + optional: true + + '@openai/codex@0.149.0-linux-x64': + optional: true + + '@openai/codex@0.149.0-win32-arm64': + optional: true + + '@openai/codex@0.149.0-win32-x64': + optional: true + '@oslojs/encoding@1.1.0': {} '@pkgjs/parseargs@0.11.0': diff --git a/scripts/check_codex_runtime.mjs b/scripts/check_codex_runtime.mjs new file mode 100644 index 0000000..d5fc5a2 --- /dev/null +++ b/scripts/check_codex_runtime.mjs @@ -0,0 +1,78 @@ +import { createHash } from 'node:crypto' +import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const root = resolve(import.meta.dirname, '..') +const manifest = JSON.parse(readFileSync(join(root, 'data', 'codex', 'manifest.json'), 'utf8')) +const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) +const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) ?? 'dev' +const requested = process.argv.filter((arg) => arg.startsWith('--platform=')).map((arg) => arg.slice('--platform='.length)) +const targets = requested.length ? [...new Set(requested)] : [`${process.platform}-${process.arch}`] +const failures = [] + +if (!existsSync(join(root, 'data', 'codex', 'LICENSE'))) { + failures.push('Codex Apache-2.0 license is missing') +} + +if (manifest.version !== packageJson.devDependencies?.['@openai/codex']) { + failures.push(`manifest version ${manifest.version} does not match @openai/codex ${packageJson.devDependencies?.['@openai/codex'] ?? 'missing'}`) +} +for (const target of ['darwin-arm64', 'darwin-x64', 'win32-x64', 'linux-x64', 'linux-arm64']) { + if (!manifest.targets?.[target]) failures.push(`manifest target is missing: ${target}`) +} + +async function sha256(path) { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return hash.digest('hex') +} + +function installedDevelopmentBinary(target) { + if (target !== `${process.platform}-${process.arch}`) return '' + try { + const require = createRequire(join(root, 'package.json')) + const packagePath = require.resolve('@openai/codex/package.json') + const platformPackage = require.resolve(`@openai/codex-${target}/package.json`, { paths: [dirname(packagePath)] }) + const triple = target === 'darwin-arm64' ? 'aarch64-apple-darwin' + : target === 'darwin-x64' ? 'x86_64-apple-darwin' + : target === 'win32-x64' ? 'x86_64-pc-windows-msvc' + : target === 'linux-x64' ? 'x86_64-unknown-linux-musl' + : target === 'linux-arm64' ? 'aarch64-unknown-linux-musl' + : '' + return join(dirname(platformPackage), 'vendor', triple, 'bin', target.startsWith('win32-') ? 'codex.exe' : 'codex') + } catch { + return '' + } +} + +for (const target of targets) { + const asset = manifest.targets?.[target] + if (!asset) { + failures.push(`unsupported target: ${target}`) + continue + } + const prepared = join(root, 'data', 'codex', 'bin', target, asset.executable) + const candidate = existsSync(prepared) ? prepared : mode === 'dev' ? installedDevelopmentBinary(target) : '' + if (!candidate || !existsSync(candidate)) { + failures.push(`${target} runtime is missing; run prepare:codex-runtime for this platform`) + continue + } + const size = statSync(candidate).size + const digest = await sha256(candidate) + if (size !== asset.bytes) failures.push(`${target} size mismatch: ${size}`) + if (digest !== asset.sha256) failures.push(`${target} SHA256 mismatch: ${digest}`) + if (target === `${process.platform}-${process.arch}`) { + const version = spawnSync(candidate, ['--version'], { encoding: 'utf8' }) + if (version.status !== 0 || !`${version.stdout}${version.stderr}`.includes(manifest.version)) { + failures.push(`${target} failed to report Codex ${manifest.version}`) + } + } +} + +if (failures.length) { + for (const failure of failures) console.error(`[codex-runtime] ${failure}`) + process.exit(1) +} +console.log(`[codex-runtime] ${mode} check passed for ${targets.join(', ')}`) diff --git a/scripts/prepare_codex_runtime.mjs b/scripts/prepare_codex_runtime.mjs new file mode 100644 index 0000000..2061570 --- /dev/null +++ b/scripts/prepare_codex_runtime.mjs @@ -0,0 +1,69 @@ +import { createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { spawnSync } from 'node:child_process' + +const root = resolve(import.meta.dirname, '..') +const manifest = JSON.parse(readFileSync(join(root, 'data', 'codex', 'manifest.json'), 'utf8')) +const requested = process.argv.slice(2) + .filter((arg) => arg.startsWith('--platform=')) + .map((arg) => arg.slice('--platform='.length)) +const targets = requested.length ? [...new Set(requested)] : [`${process.platform}-${process.arch}`] + +async function sha256(path) { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return hash.digest('hex') +} + +async function download(url, destination) { + const response = await fetch(url, { redirect: 'follow' }) + if (!response.ok || !response.body) throw new Error(`Codex runtime download failed: ${response.status} ${url}`) + await pipeline(Readable.fromWeb(response.body), createWriteStream(destination)) +} + +for (const target of targets) { + const asset = manifest.targets[target] + if (!asset) throw new Error(`Unsupported Codex runtime target: ${target}`) + const destination = join(root, 'data', 'codex', 'bin', target, asset.executable) + if (existsSync(destination) && statSync(destination).size === asset.bytes && await sha256(destination) === asset.sha256) { + console.log(`[codex-runtime] ${target} already verified`) + continue + } + + const temporary = mkdtempSync(join(tmpdir(), `goagent-codex-${target}-`)) + try { + const archive = join(temporary, basename(new URL(asset.url).pathname)) + await download(asset.url, archive) + const extracted = join(temporary, 'extracted') + mkdirSync(extracted, { recursive: true }) + const unpack = spawnSync('tar', ['-xzf', archive, '-C', extracted], { encoding: 'utf8' }) + if (unpack.status !== 0) throw new Error(`Unable to extract Codex runtime: ${unpack.stderr || unpack.stdout}`) + const source = join(extracted, ...asset.archivePath.split('/')) + if (!existsSync(source)) throw new Error(`Codex runtime archive is missing ${asset.archivePath}`) + const digest = await sha256(source) + if (statSync(source).size !== asset.bytes || digest !== asset.sha256) { + throw new Error(`Codex runtime checksum mismatch for ${target}`) + } + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(source, destination) + if (!target.startsWith('win32-')) chmodSync(destination, 0o755) + console.log(`[codex-runtime] prepared ${target} (${asset.bytes} bytes)`) + } finally { + rmSync(temporary, { recursive: true, force: true }) + } +} diff --git a/scripts/smoke_codex_packaged_runtime.mjs b/scripts/smoke_codex_packaged_runtime.mjs new file mode 100644 index 0000000..0f4380d --- /dev/null +++ b/scripts/smoke_codex_packaged_runtime.mjs @@ -0,0 +1,103 @@ +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' + +const root = resolve(process.cwd(), process.argv.find((arg) => arg.startsWith('--root='))?.slice('--root='.length) ?? 'release') +const expectedTargets = process.argv + .filter((arg) => arg.startsWith('--expect=')) + .map((arg) => arg.slice('--expect='.length)) +const manifestPaths = [] + +function walk(directory) { + if (!existsSync(directory)) return + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + walk(path) + } else if (entry.isFile() && entry.name === 'manifest.json' && path.includes(`${join('data', 'codex')}`)) { + manifestPaths.push(path) + } + } +} + +async function sha256(path) { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return hash.digest('hex') +} + +walk(root) +if (!manifestPaths.length) { + console.error(`[codex-package-smoke] no packaged Codex manifest found below ${root}`) + process.exit(1) +} + +const verifiedTargets = new Set() +const failures = [] +const signedTransforms = [] + +function startsAsExpectedRuntime(executable, target, version) { + if (target !== `${process.platform}-${process.arch}`) return false + const result = spawnSync(executable, ['--version'], { encoding: 'utf8' }) + return result.status === 0 && `${result.stdout ?? ''}${result.stderr ?? ''}`.includes(version) +} + +function validSignedMacRuntime(executable, target) { + if (!target.startsWith('darwin-') || process.platform !== 'darwin') return false + const signature = spawnSync('codesign', ['--verify', '--strict', '--verbose=2', executable], { encoding: 'utf8' }) + if (signature.status !== 0) return false + const architecture = spawnSync('file', ['-b', executable], { encoding: 'utf8' }) + const expectedArchitecture = target.endsWith('-arm64') ? 'arm64' : 'x86_64' + return architecture.status === 0 && `${architecture.stdout}${architecture.stderr}`.includes(expectedArchitecture) +} +for (const manifestPath of manifestPaths) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + const codexRoot = dirname(manifestPath) + if (!existsSync(join(codexRoot, 'LICENSE'))) { + failures.push(`${manifestPath}: packaged Codex license is missing`) + } + const packagedTargets = existsSync(join(codexRoot, 'bin')) + ? readdirSync(join(codexRoot, 'bin'), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) + : [] + for (const target of packagedTargets) { + const asset = manifest.targets?.[target] + if (!asset) { + failures.push(`${manifestPath}: target ${target} is not declared in the manifest`) + continue + } + const executable = join(codexRoot, 'bin', target, asset.executable) + if (!existsSync(executable)) { + failures.push(`${manifestPath}: missing ${target}/${asset.executable}`) + continue + } + const size = statSync(executable).size + const digest = await sha256(executable) + const exactSourceMatch = size === asset.bytes && digest === asset.sha256 + const startsCorrectly = startsAsExpectedRuntime(executable, target, manifest.version) + const signedMacRuntime = validSignedMacRuntime(executable, target) + if (!exactSourceMatch && !signedMacRuntime && !(target.startsWith('win32-') && startsCorrectly)) { + failures.push(`${manifestPath}: ${target} differs from the verified source and has no valid packaged signature/runtime evidence (${size}, ${digest})`) + } else if (!exactSourceMatch) { + signedTransforms.push(target) + } + + if (target === `${process.platform}-${process.arch}` && !startsCorrectly) { + failures.push(`${manifestPath}: ${target} failed to start as Codex ${manifest.version}`) + } + verifiedTargets.add(target) + } +} + +for (const target of expectedTargets) { + if (!verifiedTargets.has(target)) failures.push(`expected packaged target was not found: ${target}`) +} +if (failures.length) { + for (const failure of failures) console.error(`[codex-package-smoke] ${failure}`) + process.exit(1) +} + +console.log(`[codex-package-smoke] verified ${[...verifiedTargets].sort().join(', ')} in ${basename(root)}`) +if (signedTransforms.length) { + console.log(`[codex-package-smoke] accepted signed package transforms for ${[...new Set(signedTransforms)].sort().join(', ')}`) +} diff --git a/src/main/index.ts b/src/main/index.ts index e69b466..d29a0d6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,8 @@ import type { LibraryDeleteRequest, LlmModelsListRequest, LlmSettingsTestRequest, + LlmConnectionActionResult, + LlmConnectionState, ReviewRequest, TeacherBoardImageRenderImage, TeacherBoardImageRenderRequest, @@ -44,6 +46,7 @@ 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' @@ -334,18 +337,21 @@ function buildApplicationMenu(): void { Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } -async function dashboard(): Promise { +async function dashboard(llmConnectionOverride?: LlmConnectionState): Promise { const hydratedSettings = await applyDetectedDefaults(getSettings()) replaceSettings(hydratedSettings) - const publicSettings = { ...hydratedSettings, llmApiKey: '', ttsCustomApiKey: '', ttsVolcengineApiKey: '', ttsVolcengineAccessToken: '', ikatagoPassword: '', zhiziToken: '' } const detectedProfile = await detectSystemProfile(hydratedSettings) + const llmConnection = llmConnectionOverride ?? await inspectLlmConnection(hydratedSettings) + 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 }, } } @@ -596,6 +602,43 @@ 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) void shell.openExternal(url).catch((error) => { + console.error('[llm] unable to open ChatGPT login URL', error) + }) + const settings = getSettings() + const profile = settings.llmConnections.find((connection) => connection.id === settings.activeLlmConnectionId) + const llmConnection: LlmConnectionState = login + ? { + connectionId: login.connectionId, + provider: 'codex-app-server', + authMode: 'managed-login', + ready: false, + status: 'signed-out', + message: '请在浏览器完成 ChatGPT 登录。' + } + : await inspectLlmConnection(settings) + if (!profile || profile.provider !== 'codex-app-server') { + throw new Error('ChatGPT 登录配置没有正确启用。') + } + return { ...(login ? { login } : {}), dashboard: await dashboard(llmConnection) } + }) + ipcMain.handle('llm:chatgpt-logout', async (): Promise => { + await logoutChatGpt() + const settings = getSettings() + const profile = settings.llmConnections.find((connection) => connection.id === settings.activeLlmConnectionId) + const llmConnection: LlmConnectionState = { + connectionId: profile?.id ?? 'chatgpt-codex', + provider: 'codex-app-server', + authMode: 'managed-login', + ready: false, + status: 'signed-out', + message: '已退出 ChatGPT。' + } + return { dashboard: await dashboard(llmConnection) } + }) ipcMain.handle('llm:get-saved-api-key', async () => { const settings = getSettings() return { @@ -798,5 +841,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..361d6af 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, LlmSetupStatus } 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,36 @@ 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-mini' + +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, + setupStatus: 'unconfigured', + lastVerifiedAt: '' + }, + { + id: CHATGPT_LLM_CONNECTION_ID, + name: 'ChatGPT 登录', + provider: 'codex-app-server', + authMode: 'managed-login', + model: '', + enabled: true, + setupStatus: 'unconfigured', + lastVerifiedAt: '' + } + ] +} + for (const dir of [appHome, electronUserData, libraryDir, reviewsDir, cacheDir, reportsDir]) { mkdirSync(dir, { recursive: true }) } @@ -78,7 +108,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 +167,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 +233,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 +257,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 +414,72 @@ function migrateZhiziOfficialSettings(settings: AppSettings): AppSettings { return migrated } +function normalizeLlmConnections(settings: AppSettings): AppSettings { + const migratingLegacySettings = settings.llmConnectionSchemaVersion < 1 + 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 + const legacyWasActive = migratingLegacySettings || settings.activeLlmConnectionId === LEGACY_LLM_CONNECTION_ID + 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: legacyModel, + enabled: legacy?.enabled !== false, + setupStatus: legacy?.setupStatus ?? (legacyWasActive ? settings.llmSetupStatus : 'unconfigured'), + lastVerifiedAt: legacy?.lastVerifiedAt ?? (legacyWasActive ? settings.llmLastVerifiedAt : '') + }) + const chatgpt = byId.get(CHATGPT_LLM_CONNECTION_ID) + const chatGptWasActive = !migratingLegacySettings && settings.activeLlmConnectionId === 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: chatgpt?.model || '', + executablePath: chatgpt?.executablePath, + enabled: chatgpt?.enabled !== false, + setupStatus: chatgpt?.setupStatus ?? (chatGptWasActive ? settings.llmSetupStatus : 'unconfigured'), + lastVerifiedAt: chatgpt?.lastVerifiedAt ?? (chatGptWasActive ? settings.llmLastVerifiedAt : '') + }) + const llmConnections = [...byId.values()] + const activeLlmConnectionId = byId.has(settings.activeLlmConnectionId) + ? settings.activeLlmConnectionId + : LEGACY_LLM_CONNECTION_ID + const active = byId.get(activeLlmConnectionId) + const llmSetupStatus = active?.setupStatus ?? 'unconfigured' + const llmLastVerifiedAt = active?.lastVerifiedAt ?? '' + const migrated = { ...settings, activeLlmConnectionId, llmConnections, llmConnectionSchemaVersion: 3, llmSetupStatus, llmLastVerifiedAt } + if ( + settings.llmConnectionSchemaVersion !== 3 || + settings.activeLlmConnectionId !== activeLlmConnectionId || + settings.llmSetupStatus !== llmSetupStatus || + settings.llmLastVerifiedAt !== llmLastVerifiedAt || + JSON.stringify(settings.llmConnections) !== JSON.stringify(llmConnections) + ) { + settingsStore.set({ activeLlmConnectionId, llmConnections, llmConnectionSchemaVersion: 3, llmSetupStatus, llmLastVerifiedAt }) + } + 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 +490,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 +518,72 @@ 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 + } + } + const targetConnectionId = safeNext.activeLlmConnectionId || currentBeforeWrite.activeLlmConnectionId + const candidateConnections = safeNext.llmConnections ?? currentBeforeWrite.llmConnections + const previousById = new Map(currentBeforeWrite.llmConnections.map((connection) => [connection.id, connection])) + const apiKeyChanged = typeof next.llmApiKey === 'string' && next.llmApiKey.trim().length > 0 + const connectionsWithVerification: LlmConnectionProfile[] = candidateConnections.map((connection): LlmConnectionProfile => { + const previous = previousById.get(connection.id) + const configurationChanged = + previous?.provider !== connection.provider || + previous?.endpoint !== connection.endpoint || + previous?.model !== connection.model || + previous?.executablePath !== connection.executablePath || + (connection.id === targetConnectionId && apiKeyChanged) + if (connection.id !== targetConnectionId) return connection + if (Object.prototype.hasOwnProperty.call(next, 'llmSetupStatus')) { + return { + ...connection, + setupStatus: next.llmSetupStatus, + lastVerifiedAt: next.llmLastVerifiedAt ?? (next.llmSetupStatus === 'verified' ? connection.lastVerifiedAt ?? '' : '') + } + } + if (!configurationChanged) return connection + const configured = connection.provider === 'codex-app-server' + ? false + : Boolean(connection.endpoint?.trim() && getLlmApiKey(connection.id).trim() && connection.model.trim()) + const setupStatus: LlmSetupStatus = configured ? 'needs-attention' : 'unconfigured' + return { + ...connection, + setupStatus, + lastVerifiedAt: '' + } + }) + if ( + safeNext.llmConnections || + safeNext.activeLlmConnectionId || + legacyFieldsChanged || + apiKeyChanged || + Object.prototype.hasOwnProperty.call(next, 'llmSetupStatus') + ) { + safeNext.llmConnections = connectionsWithVerification + safeNext.llmConnectionSchemaVersion = 3 + const target = connectionsWithVerification.find((connection) => connection.id === targetConnectionId) + safeNext.llmSetupStatus = target?.setupStatus ?? 'unconfigured' + safeNext.llmLastVerifiedAt = target?.lastVerifiedAt ?? '' + } delete safeNext.zhiziClientBin delete safeNext.zhiziExtraArgs delete safeNext.zhiziUseWhenLocalSlow @@ -420,24 +591,12 @@ export function setSettings(next: Partial): AppSettings { Object.prototype.hasOwnProperty.call(safeNext, 'katagoEngineMode') || Object.prototype.hasOwnProperty.call(safeNext, 'ikatagoUseWhenLocalSlow') settingsStore.set(shouldMarkLocalDefaultApplied ? { ...safeNext, localAnalysisDefaultApplied: true } : safeNext) - const llmConfigChanged = - Object.prototype.hasOwnProperty.call(next, 'llmBaseUrl') || - Object.prototype.hasOwnProperty.call(next, 'llmApiKey') || - Object.prototype.hasOwnProperty.call(next, 'llmModel') - if (llmConfigChanged && !Object.prototype.hasOwnProperty.call(next, 'llmSetupStatus')) { - const current = getSettings() - const configured = Boolean(current.llmBaseUrl.trim() && current.llmApiKey.trim() && current.llmModel.trim()) - settingsStore.set({ - llmSetupStatus: configured ? 'needs-attention' : 'unconfigured', - llmLastVerifiedAt: '' - }) - } return getSettings() } 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 +643,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 6c884b5..5fd24ad 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -82,6 +82,32 @@ 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 + setupStatus?: LlmSetupStatus + lastVerifiedAt?: string +} + +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 +150,9 @@ export interface AppSettings { llmBaseUrl: string llmApiKey: string llmModel: string + activeLlmConnectionId: string + llmConnections: LlmConnectionProfile[] + llmConnectionSchemaVersion: number onboardingVersion: number llmSetupStatus: LlmSetupStatus llmLastVerifiedAt: string @@ -405,6 +434,7 @@ export interface SystemProfile { proxyApiKey: string proxyModels: string[] hasLlmApiKey: boolean + llmConnection: LlmConnectionState hasZhiziToken: boolean notes: string[] } @@ -1443,6 +1473,7 @@ export interface LlmSettingsTestRequest { llmBaseUrl: string llmApiKey: string llmModel: string + connectionId?: string } export interface LlmSettingsTestResult { @@ -1464,11 +1495,13 @@ export interface LlmCapabilityCheck { export interface LlmModelsListRequest { llmBaseUrl: string llmApiKey: string + connectionId?: string } export interface LlmModelsListResult { ok: boolean models: string[] + recommendedModel?: string message: string } @@ -1477,6 +1510,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/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/agentRuntime.ts b/src/main/services/llm/agentRuntime.ts new file mode 100644 index 0000000..c54d08b --- /dev/null +++ b/src/main/services/llm/agentRuntime.ts @@ -0,0 +1,43 @@ +import type { + LlmConnectionProfile, + LlmModelsListResult, + LlmProviderId, + LlmSettingsTestResult +} from '@main/lib/types' +import type { ChatMessage, ChatTool, ChatToolCall, ChatTurnResult } from './provider' + +export interface AgentRuntimeCapabilities { + text: boolean + vision: boolean + tools: boolean + streaming: boolean + cancellation: boolean +} + +export interface AgentToolExecutionResult { + ok: boolean + toolResult: string + followupMessages: ChatMessage[] +} + +export type AgentToolExecutor = (call: ChatToolCall) => Promise + +export interface AgentRuntimeTurnInput { + profile: LlmConnectionProfile + messages: ChatMessage[] + tools: ChatTool[] + maxTokens: number + onDelta?: (delta: string) => void + signal?: AbortSignal + executeTool?: AgentToolExecutor +} + +export interface AgentRuntimeAdapter { + readonly id: LlmProviderId + readonly capabilities: AgentRuntimeCapabilities + probe(profile: LlmConnectionProfile): Promise + listModels(profile: LlmConnectionProfile): Promise + runTurn(input: AgentRuntimeTurnInput): Promise + cancel(runId?: string): Promise + dispose(): void +} diff --git a/src/main/services/llm/codexAppServerAgentRuntime.ts b/src/main/services/llm/codexAppServerAgentRuntime.ts new file mode 100644 index 0000000..9a53988 --- /dev/null +++ b/src/main/services/llm/codexAppServerAgentRuntime.ts @@ -0,0 +1,210 @@ +import type { + LlmConnectionProfile, + LlmConnectionState, + LlmLoginStartResult, + LlmModelsListResult, + LlmSettingsTestResult +} from '@main/lib/types' +import type { AgentRuntimeAdapter, AgentRuntimeTurnInput, AgentToolExecutor } from './agentRuntime' +import { + CodexAppServerClient, + type CodexAvailableModel +} from './codexAppServerClient' +import type { ChatMessage, ChatTool } from './provider' + +const probeImages = [ + { + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAbklEQVR42u3aUQnAMAxAwabMx1xMQU1U4bzURRXMQi2MblAK9/4DOchn4ikl7VxOmwcAAAAAAAAAAAAAAACwquPL8HXef+3RenVCAAAAAAAAAAAAAAAAAAAAAADvC88eAAAAAAAAAAAAAAAAAHMNV74Gb7Wxx20AAAAASUVORK5CYII=', + colors: ['red', 'blue'], + localizedColors: ['红', '蓝'] + }, + { + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAc0lEQVR42u3YwQmAMAwF0CiF7uHJVbpE1+wE3cFVvLmDFaXy/j2QR3L6y9ZrzJw1Jg8AAAAAAAAAAAAAAADAV0kjw0duT+2xn8ULAQAAAAAAAAAAAAAAAAAAAADEO8XW7TbKBQAAAAAAAAAAAAAAAH4AuAAyewYz9K84vQAAAABJRU5ErkJggg==', + colors: ['green', 'yellow'], + localizedColors: ['绿', '黄'] + } +] as const + +function selectModel(profile: LlmConnectionProfile, models: CodexAvailableModel[]): CodexAvailableModel | undefined { + return models.find((model) => model.id === profile.model) + ?? models.find((model) => model.supportsImage === true && model.isDefault) + ?? models.find((model) => model.isDefault) + ?? models.find((model) => model.supportsImage === true) + ?? models[0] +} + +function probeMessages(text: string, imageUrl?: string): ChatMessage[] { + return [ + { role: 'system', content: '这是 GoAgent 连接能力测试。严格完成用户要求,不补充其他内容。' }, + { + role: 'user', + content: imageUrl + ? [ + { type: 'text', text }, + { type: 'image_url', image_url: { url: imageUrl, detail: 'high' } } + ] + : text + } + ] +} + +export class CodexAppServerAgentRuntime implements AgentRuntimeAdapter { + readonly id = 'codex-app-server' as const + readonly capabilities = { + text: true, + vision: true, + tools: true, + streaming: true, + cancellation: true + } + + private readonly client: CodexAppServerClient + + constructor(executablePath = '') { + this.client = new CodexAppServerClient(executablePath) + } + + connectionState(connectionId: string): Promise { + return this.client.connectionState(connectionId) + } + + startLogin(connectionId: string, useDeviceCode = false): Promise { + return this.client.startLogin(connectionId, useDeviceCode) + } + + logout(): Promise { + return this.client.logout() + } + + availableModels(): Promise { + return this.client.listModels() + } + + async listModels(profile: LlmConnectionProfile): Promise { + try { + const available = await this.availableModels() + const selected = selectModel(profile, available) + 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} 个模型。` : '当前账号没有返回可用模型。' + } + } catch (error) { + return { ok: false, models: [], message: String(error) } + } + } + + async probe(profile: LlmConnectionProfile): Promise { + const state = await this.connectionState(profile.id) + if (!state.ready) { + const failed = { ok: false, message: state.message } + return { + ok: false, + message: state.message, + capabilities: { text: failed, vision: failed, tools: failed } + } + } + + const models = await this.availableModels() + const selected = selectModel(profile, models) + if (!selected) { + const failed = { ok: false, message: '当前 ChatGPT 账号没有返回可用模型。' } + return { ok: false, message: failed.message, capabilities: { text: failed, vision: failed, tools: failed } } + } + const probeProfile = { ...profile, model: selected.id } + + let textCheck = { ok: false, message: '文字能力测试未完成。', technicalDetail: undefined as string | undefined } + try { + const result = await this.runTurn({ + profile: probeProfile, + messages: probeMessages('只回复 GOAGENT_TEXT_OK'), + tools: [], + maxTokens: 64 + }) + const ok = /GOAGENT_TEXT_OK/i.test(result.text) + textCheck = { ok, message: ok ? '文字回复正常。' : '模型回复了内容,但没有按测试要求返回。', technicalDetail: ok ? undefined : result.text.slice(0, 200) } + } catch (error) { + textCheck = { ok: false, message: '文字回复测试失败。', technicalDetail: String(error) } + } + + const image = probeImages[Math.floor(Math.random() * probeImages.length)] + let visionCheck = { ok: false, message: '图片能力测试未完成。', technicalDetail: undefined as string | undefined } + try { + const result = await this.runTurn({ + profile: probeProfile, + messages: probeMessages('请观察图片,只回答背景色和中心方块颜色。', image.dataUrl), + tools: [], + maxTokens: 80 + }) + const normalized = result.text.toLowerCase() + const englishMatch = image.colors.every((color) => normalized.includes(color)) + const localizedMatch = image.localizedColors.every((color) => result.text.includes(color)) + const ok = englishMatch || localizedMatch + visionCheck = { ok, message: ok ? '图片识别正常。' : '模型回复了内容,但没有正确识别测试图片。', technicalDetail: ok ? undefined : result.text.slice(0, 200) } + } catch (error) { + visionCheck = { ok: false, message: '图片识别测试失败。', technicalDetail: String(error) } + } + + const nonce = `goagent-${Date.now()}-${Math.random().toString(16).slice(2)}` + let toolCalled = false + const tool: ChatTool = { + type: 'function', + function: { + name: 'goagent_healthEcho', + description: 'GoAgent 连接测试工具。调用时原样传入 nonce。', + parameters: { + type: 'object', + additionalProperties: false, + properties: { nonce: { type: 'string' } }, + required: ['nonce'] + } + } + } + const executeTool: AgentToolExecutor = async (call) => { + const args = JSON.parse(call.function.arguments) as { nonce?: string } + toolCalled = call.function.name === tool.function.name && args.nonce === nonce + return { + ok: toolCalled, + toolResult: JSON.stringify({ ok: toolCalled, nonce: args.nonce }), + followupMessages: [] + } + } + let toolsCheck = { ok: false, message: '工具调用测试未完成。', technicalDetail: undefined as string | undefined } + try { + const result = await this.runTurn({ + profile: probeProfile, + messages: probeMessages(`必须调用 goagent_healthEcho,nonce 是 ${nonce};得到结果后只回复 GOAGENT_TOOL_OK。`), + tools: [tool], + maxTokens: 100, + executeTool + }) + const ok = toolCalled && result.executedToolCalls?.includes(tool.function.name) === true + toolsCheck = { ok, message: ok ? '围棋工具调用正常。' : '模型没有完成动态工具调用。', technicalDetail: ok ? undefined : result.text.slice(0, 200) } + } catch (error) { + toolsCheck = { ok: false, message: '围棋工具调用测试失败。', technicalDetail: String(error) } + } + + const ok = textCheck.ok && visionCheck.ok && toolsCheck.ok + return { + ok, + message: ok ? 'ChatGPT 的文字、图片和围棋工具能力均已验证。' : 'ChatGPT 连接尚未通过全部能力测试。', + capabilities: { text: textCheck, vision: visionCheck, tools: toolsCheck } + } + } + + runTurn(input: AgentRuntimeTurnInput) { + return this.client.runTurn(input) + } + + cancel(): Promise { + return this.client.cancel() + } + + dispose(): void { + this.client.dispose() + } +} diff --git a/src/main/services/llm/codexAppServerClient.ts b/src/main/services/llm/codexAppServerClient.ts new file mode 100644 index 0000000..02ba749 --- /dev/null +++ b/src/main/services/llm/codexAppServerClient.ts @@ -0,0 +1,748 @@ +import { app } from 'electron' +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { existsSync, mkdirSync, 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 { appHome } from '@main/lib/store' +import type { + LlmConnectionState, + LlmLoginStartResult, + LlmModelsListResult +} from '@main/lib/types' +import type { ChatMessage, ChatTool, ChatToolCall, ChatTurnResult } from './provider' +import type { AgentRuntimeTurnInput, AgentToolExecutor } from './agentRuntime' + +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 + timer: NodeJS.Timeout +} + +interface TurnCompletion { + status: string + error?: string +} + +export interface CodexAvailableModel { + id: string + supportsImage?: boolean + isDefault: boolean +} + +interface ActiveToolContext { + execute: AgentToolExecutor + allowedTools: Set + executedTools: string[] + followupMessages: ChatMessage[] + policyViolations: 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' } + } +} + +export const CODEX_RUNTIME_VERSION = '0.149.0' +const CODEX_HOME = join(appHome, 'codex') +const FORBIDDEN_ITEM_TYPES = new Set([ + 'commandExecution', + 'fileChange', + 'mcpToolCall', + 'webSearch', + 'imageGeneration', + 'computerToolCall', + 'collabToolCall' +]) +const MAX_DYNAMIC_TOOL_CALLS = 18 +const RPC_TIMEOUT_MS = 20_000 + +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 + } +} + +function packagedCodexExecutable(): string | null { + const target = PLATFORM_TARGETS[process.platform]?.[process.arch] + if (!target || !app.isPackaged) return null + const executable = join( + process.resourcesPath, + 'data', + 'codex', + 'bin', + `${process.platform}-${process.arch}`, + process.platform === 'win32' ? 'codex.exe' : 'codex' + ) + return existsSync(executable) ? executable : null +} + +export function resolveCodexExecutable(configuredPath = ''): string { + const explicit = configuredPath.trim() || process.env.GOAGENT_CODEX_BIN?.trim() + if (explicit) { + if (!existsSync(explicit)) throw new Error(`找不到指定的 ChatGPT 运行组件:${explicit}`) + return explicit + } + const packaged = packagedCodexExecutable() + if (packaged) return packaged + const bundled = bundledCodexExecutable() + if (bundled) return bundled + throw new Error('GoAgent 的 ChatGPT 运行组件缺失,请重新安装完整版本。') +} + +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[]): { instructions: string; text: string; imageUrls: string[] } { + const sections: string[] = [] + const instructions: string[] = [] + const imageUrls: string[] = [] + for (const message of messages) { + if (message.role === 'system') { + const text = typeof message.content === 'string' + ? message.content + : message.content.filter((part) => part.type === 'text').map((part) => part.type === 'text' ? part.text : '').join('\n') + if (text.trim()) instructions.push(text.trim()) + continue + } + const role = 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 { + instructions: instructions.join('\n\n'), + text: sections.join('\n\n'), + imageUrls + } +} + +function dynamicTools(tools: ChatTool[]): Array> { + return tools.map((tool) => ({ + type: 'function', + name: tool.function.name, + description: tool.function.description, + inputSchema: tool.function.parameters, + deferLoading: false + })) +} + +function contentItemsFromToolResult(result: Awaited>): Array> { + const contentItems: Array> = [{ type: 'inputText', text: result.toolResult }] + for (const message of result.followupMessages) { + if (typeof message.content === 'string') { + if (message.content.trim()) contentItems.push({ type: 'inputText', text: message.content }) + continue + } + for (const part of message.content) { + if (part.type === 'text' && part.text.trim()) contentItems.push({ type: 'inputText', text: part.text }) + if (part.type === 'image_url' && part.image_url.url.startsWith('data:image/')) { + contentItems.push({ type: 'inputImage', imageUrl: part.image_url.url }) + } + } + } + return contentItems +} + +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 toolContexts = new Map() + private activeTurns = 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 = '' + mkdirSync(CODEX_HOME, { recursive: true }) + const configOverrides = [ + 'cli_auth_credentials_store="file"', + 'features.shell_tool=false', + 'features.unified_exec=false', + 'features.apply_patch_freeform=false', + 'web_search="disabled"', + 'features.web_search_request=false', + 'features.image_generation=false', + 'features.apps=false', + 'features.plugins=false', + 'features.enable_mcp_apps=false', + 'features.browser_use=false', + 'features.computer_use=false', + 'features.multi_agent=false', + 'features.collab=false', + 'features.code_mode=false', + 'features.js_repl=false', + 'features.memory_tool=false', + 'features.tool_search=false', + 'features.connectors=false', + 'features.workspace_dependencies=false' + ].flatMap((value) => ['-c', value]) + const environmentKeys = [ + 'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA' + ] + const env = Object.fromEntries(environmentKeys.flatMap((key) => process.env[key] ? [[key, process.env[key] as string]] : [])) + const child = spawn(command, [...configOverrides, 'app-server', '--listen', 'stdio://'], { + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...env, CODEX_HOME } + }) + 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: true } + }, 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) + clearTimeout(pending.timer) + 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.handleServerRequest(message) + return + } + if (!message.method) return + const params = record(message.params) + if (message.method === 'item/started') { + const item = record(params.item) + const threadId = stringValue(params.threadId) + const turnId = stringValue(params.turnId) + const itemType = stringValue(item.type) + const context = this.toolContexts.get(threadId) + if (context && FORBIDDEN_ITEM_TYPES.has(itemType)) { + const violation = `Codex 尝试使用未授权能力:${itemType}` + context.policyViolations.push(violation) + if (threadId && turnId) { + void this.request('turn/interrupt', { threadId, turnId }).catch(() => undefined) + } + } + } else 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 async handleServerRequest(message: RpcResponse): Promise { + if (message.method !== 'item/tool/call') { + await this.write({ + id: message.id, + error: { code: -32601, message: `Unsupported server request: ${message.method}` } + }).catch(() => undefined) + return + } + + const params = record(message.params) + const threadId = stringValue(params.threadId) + const callId = stringValue(params.callId) + const toolName = stringValue(params.tool) + const context = this.toolContexts.get(threadId) + if (!context || !context.allowedTools.has(toolName)) { + await this.write({ + id: message.id, + result: { + contentItems: [{ type: 'inputText', text: `工具不可用或未授权:${toolName || 'unknown'}` }], + success: false + } + }).catch(() => undefined) + return + } + if (context.executedTools.length >= MAX_DYNAMIC_TOOL_CALLS) { + await this.write({ + id: message.id, + result: { + contentItems: [{ type: 'inputText', text: '本轮工具调用次数已达到上限,请根据已有证据给出最终回答。' }], + success: false + } + }).catch(() => undefined) + return + } + + const toolCall: ChatToolCall = { + id: callId || `codex-tool-${Date.now()}`, + type: 'function', + function: { + name: toolName, + arguments: JSON.stringify(params.arguments ?? {}) + } + } + try { + const result = await context.execute(toolCall) + if (result.ok) context.executedTools.push(toolName) + context.followupMessages.push(...result.followupMessages) + await this.write({ + id: message.id, + result: { contentItems: contentItemsFromToolResult(result), success: result.ok } + }) + } catch (error) { + await this.write({ + id: message.id, + result: { + contentItems: [{ type: 'inputText', text: `工具执行失败:${String(error)}` }], + success: false + } + }).catch(() => undefined) + } + } + + 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) => { + const timer = setTimeout(() => { + if (!this.pending.has(id)) return + const failure = new CodexTransportError(`Codex App Server 请求超时:${method}`) + const child = this.child + if (child) { + this.handleProcessFailure(child, failure) + } else { + this.pending.delete(id) + reject(failure) + } + }, RPC_TIMEOUT_MS) + this.pending.set(id, { resolve, reject, timer }) + }) + // 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) { + const pending = this.pending.get(id) + if (pending) clearTimeout(pending.timer) + 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()) { + clearTimeout(pending.timer) + 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 : null + return { + id: stringValue(model.model) || stringValue(model.id), + supportsImage: modalities ? modalities.includes('image') : undefined, + isDefault: model.isDefault === true + } + }).filter((model) => model.id) + } + + async runTurn(inputOptions: AgentRuntimeTurnInput): Promise { + await this.ensureStarted() + const { profile, messages, tools, onDelta, signal, executeTool } = inputOptions + if (tools.length > 0 && !executeTool) { + throw new Error('ChatGPT 工具执行器未连接,无法开始围棋分析。') + } + const { instructions, 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?.supportsImage === false) { + throw new Error(`模型 ${model} 不支持棋盘图片输入,请选择多模态模型。`) + } + const threadResult = record(await this.request('thread/start', { + ...(model ? { model } : {}), + cwd: tempRoot, + approvalPolicy: 'never', + sandbox: 'read-only', + ephemeral: true, + serviceName: 'goagent', + baseInstructions: instructions || '你是 GoAgent 的围棋老师。', + developerInstructions: '你运行在 GoAgent 内。只能使用本轮提供的动态工具;不得执行命令、修改文件、调用外部工具或访问未提供的数据。', + dynamicTools: dynamicTools(tools), + config: { + web_search: 'disabled', + features: { + shell_tool: false, + unified_exec: false, + apply_patch_freeform: false, + image_generation: false, + apps: false, + plugins: false, + browser_use: false, + computer_use: false, + multi_agent: false, + collab: false, + code_mode: false, + js_repl: false, + memory_tool: false, + tool_search: false, + connectors: false, + workspace_dependencies: false + } + } + })) + threadId = stringValue(record(threadResult.thread).id) + if (!threadId) throw new Error('Codex 未返回 thread id。') + const toolContext: ActiveToolContext = { + execute: executeTool ?? (async () => ({ ok: false, toolResult: '工具执行器不可用。', followupMessages: [] })), + allowedTools: new Set(tools.map((tool) => tool.function.name)), + executedTools: [], + followupMessages: [], + policyViolations: [] + } + this.toolContexts.set(threadId, toolContext) + const turnResult = record(await this.request('turn/start', { + threadId, + input, + ...(model ? { model } : {}), + cwd: tempRoot, + approvalPolicy: 'never', + sandboxPolicy: { type: 'readOnly', networkAccess: false } + })) + const turn = record(turnResult.turn) + turnId = stringValue(turn.id) + if (!turnId) throw new Error('Codex 未返回 turn id。') + this.activeTurns.set(threadId, turnId) + 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) + const completedContext = this.toolContexts.get(threadId) + if (completedContext?.policyViolations.length) { + throw new Error(completedContext.policyViolations.join(';')) + } + 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: [], + executedToolCalls: completedContext?.executedTools ?? [], + toolFollowupMessages: completedContext?.followupMessages ?? [], + 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) + this.activeTurns.delete(threadId) + this.toolContexts.delete(threadId) + if (threadId) await this.request('thread/delete', { threadId }).catch(() => undefined) + rmSync(tempRoot, { recursive: true, force: true }) + } + } + + async cancel(): Promise { + await Promise.all([...this.activeTurns].map(([threadId, turnId]) => + this.request('turn/interrupt', { threadId, turnId }).catch(() => undefined) + )) + } + + dispose(): void { + const child = this.child + this.child = null + this.started = null + this.activeTurns.clear() + this.toolContexts.clear() + 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/openAICompatibleAgentRuntime.ts b/src/main/services/llm/openAICompatibleAgentRuntime.ts new file mode 100644 index 0000000..c316246 --- /dev/null +++ b/src/main/services/llm/openAICompatibleAgentRuntime.ts @@ -0,0 +1,87 @@ +import type { LlmConnectionProfile, LlmModelsListResult, LlmSettingsTestResult } from '@main/lib/types' +import { getLlmApiKey } from '@main/lib/store' +import { + listOpenAICompatibleModels, + probeOpenAICompatibleProvider, + streamOpenAICompatibleToolTurn +} from './openaiCompatibleProvider' +import type { ProviderSettings } from './provider' +import type { AgentRuntimeAdapter, AgentRuntimeTurnInput } from './agentRuntime' + +function providerSettings(profile: LlmConnectionProfile, requireModel = true): ProviderSettings { + const llmApiKey = getLlmApiKey(profile.id) + if (!profile.endpoint?.trim() || !llmApiKey || (requireModel && !profile.model.trim())) { + throw new Error('请先填写 AI 服务地址、访问密钥和模型。') + } + return { + llmBaseUrl: profile.endpoint, + llmApiKey, + llmModel: profile.model + } +} + +function recommendedModel(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 class OpenAICompatibleAgentRuntime implements AgentRuntimeAdapter { + readonly id = 'openai-compatible' as const + readonly capabilities = { + text: true, + vision: true, + tools: true, + streaming: true, + cancellation: true + } + + async probe(profile: LlmConnectionProfile): Promise { + const result = await probeOpenAICompatibleProvider(providerSettings(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: '尚未验证工具调用。' } + } + return { ok: result.ok, message: result.message, capabilities } + } + + async listModels(profile: LlmConnectionProfile): Promise { + try { + const models = await listOpenAICompatibleModels(providerSettings(profile, false)) + return { + ok: true, + models, + recommendedModel: recommendedModel(models), + message: models.length ? `已刷新 ${models.length} 个模型。` : '连接可用,但没有返回模型列表。' + } + } catch (error) { + return { ok: false, models: [], message: String(error) } + } + } + + runTurn(input: AgentRuntimeTurnInput) { + return streamOpenAICompatibleToolTurn( + providerSettings(input.profile), + input.messages, + input.tools, + input.maxTokens, + input.onDelta, + input.signal + ) + } + + async cancel(): Promise {} + + dispose(): void {} +} diff --git a/src/main/services/llm/provider.ts b/src/main/services/llm/provider.ts index f62290d..5ffb53f 100644 --- a/src/main/services/llm/provider.ts +++ b/src/main/services/llm/provider.ts @@ -50,6 +50,8 @@ export interface ChatResult { export interface ChatTurnResult { text: string toolCalls: ChatToolCall[] + executedToolCalls?: string[] + toolFollowupMessages?: ChatMessage[] raw?: unknown finishReason?: string } diff --git a/src/main/services/llm/providerRegistry.ts b/src/main/services/llm/providerRegistry.ts new file mode 100644 index 0000000..0cf726d --- /dev/null +++ b/src/main/services/llm/providerRegistry.ts @@ -0,0 +1,204 @@ +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 } from './provider' +import type { AgentRuntimeAdapter, AgentToolExecutor } from './agentRuntime' +import { CodexAppServerAgentRuntime } from './codexAppServerAgentRuntime' +import type { CodexAvailableModel } from './codexAppServerClient' +import { OpenAICompatibleAgentRuntime } from './openAICompatibleAgentRuntime' + +const openAICompatibleRuntime = new OpenAICompatibleAgentRuntime() +let codexRuntime: CodexAppServerAgentRuntime | null = null +let codexExecutablePath = '' + +const CODEX_DOMAIN_TOOL_NAMES = new Set([ + 'library_findGames', + 'sgf_readGameRecord', + 'katago_analyzePosition', + 'katago_analyzeGameBatch', + 'katago_analyzeMoveRangeKeyMoves', + 'katago_getAnalysisCache', + 'katago_getTracePacket', + 'katago_compareMoves', + 'katago_verifyAnalysis', + 'board_captureTeachingImage', + 'knowledge_searchLocal', + 'knowledge_matchPosition', + 'knowledge_searchJoseki', + 'knowledge_searchLifeDeath', + 'knowledge_searchTesuji', + 'knowledge_recommendProblems', + 'studentProfile_read', + 'studentProfile_write', + 'artifact_createTeachingArtifact', + 'report_saveAnalysis' +]) + +export function toolsForLlmConnection(profile: LlmConnectionProfile, tools: ChatTool[]): ChatTool[] { + if (profile.provider !== 'codex-app-server') return tools + return tools.filter((tool) => CODEX_DOMAIN_TOOL_NAMES.has(tool.function.name)) +} + +function codexRuntimeFor(profile: LlmConnectionProfile): CodexAppServerAgentRuntime { + const executablePath = profile.executablePath?.trim() || '' + if (!codexRuntime || executablePath !== codexExecutablePath) { + codexRuntime?.dispose() + codexExecutablePath = executablePath + codexRuntime = new CodexAppServerAgentRuntime(executablePath) + } + return codexRuntime +} + +function runtimeFor(profile: LlmConnectionProfile): AgentRuntimeAdapter { + return profile.provider === 'codex-app-server' ? codexRuntimeFor(profile) : openAICompatibleRuntime +} + +export function resolveLlmConnection(settings: AppSettings = getSettings(), connectionId?: string): LlmConnectionProfile { + return settings.llmConnections.find((item) => item.id === connectionId) + ?? getActiveLlmConnection(settings) +} + +function selectCodexModel(profile: LlmConnectionProfile, models: CodexAvailableModel[]): CodexAvailableModel | undefined { + return models.find((model) => model.id === profile.model) + ?? models.find((model) => model.supportsImage === true && model.isDefault) + ?? models.find((model) => model.isDefault) + ?? models.find((model) => model.supportsImage === true) + ?? models[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) + }) +} + +export function activeProviderSupportsTools(settings: AppSettings = getSettings()): boolean { + return runtimeFor(getActiveLlmConnection(settings)).capabilities.tools +} + +export async function inspectLlmConnection(settings: AppSettings = getSettings()): Promise { + const profile = getActiveLlmConnection(settings) + if (profile.provider === 'codex-app-server') { + const state = await codexRuntimeFor(profile).connectionState(profile.id) + if (!state.ready) return state + try { + const models = await codexRuntimeFor(profile).availableModels() + const selected = selectCodexModel(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) + const result = await runtimeFor(profile).probe(profile) + setSettings({ llmSetupStatus: result.ok ? 'verified' : 'needs-attention', llmLastVerifiedAt: result.ok ? new Date().toISOString() : '' }) + return result +} + +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 codexRuntimeFor(profile).availableModels() + const selected = selectCodexModel(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} 个模型。` : '当前账号没有返回可用模型。' + } + } + return await openAICompatibleRuntime.listModels(profile) + } 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 codexRuntimeFor(profile).connectionState(profile.id) + if (state.ready) { + const models = await codexRuntimeFor(profile).availableModels() + const selected = selectCodexModel(profile, models) + if (!selected) throw new Error('当前 ChatGPT 账号没有返回可用模型。') + persistConnectionModel(profile.id, selected.id) + setSettings({ llmSetupStatus: 'needs-attention', llmLastVerifiedAt: '' }) + return undefined + } + if (state.status === 'unavailable') throw new Error(state.message) + return codexRuntimeFor(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 codexRuntimeFor(profile).logout() + setSettings({ llmSetupStatus: 'unconfigured', llmLastVerifiedAt: '' }) +} + +export async function runProviderTurn( + settings: AppSettings, + messages: ChatMessage[], + tools: ChatTool[], + maxTokens: number, + onDelta?: (delta: string) => void, + signal?: AbortSignal, + executeTool?: AgentToolExecutor +): Promise { + const profile = getActiveLlmConnection(settings) + return runtimeFor(profile).runTurn({ + profile, + messages, + tools: toolsForLlmConnection(profile, tools), + maxTokens, + onDelta, + signal, + executeTool + }) +} + +export function disposeLlmProviders(): void { + openAICompatibleRuntime.dispose() + codexRuntime?.dispose() + codexRuntime = null + codexExecutablePath = '' +} diff --git a/src/main/services/systemProfile.ts b/src/main/services/systemProfile.ts index 2c28abe..d9ad5f5 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,6 +144,10 @@ 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.5') || detected.proxyModels.find((model) => model === 'gpt-5.4-mini') || diff --git a/src/main/services/teacherAgent.ts b/src/main/services/teacherAgent.ts index 3a7f4e5..dbeec38 100644 --- a/src/main/services/teacherAgent.ts +++ b/src/main/services/teacherAgent.ts @@ -29,7 +29,7 @@ import type { VisionEvidenceImageRole, VisionEvidenceReport } from '@main/lib/types' -import type { ChatContentPart, ChatMessage, ChatTool, ChatToolCall, ChatTurnResult, ProviderSettings } from './llm/provider' +import type { ChatContentPart, ChatMessage, ChatTool, ChatToolCall, ChatTurnResult } from './llm/provider' import { analyzePosition, cancelKataGoAnalysis } from './katago' import { analyzeGameQuickRuntime } from './analysis/runtimeIntegration' import { MOVE_RANGE_KEY_MOVE_LIMIT, MOVE_RANGE_MAX_MOVES, parseMoveRangeFromPrompt, validateMoveRange } from '@shared/moveRange' @@ -68,7 +68,8 @@ import { validateVisionEvidenceForIntent } from './teacher/visionEvidence' import { buildVisionEvidenceRepairNote, verifyVisionEvidenceMarkdown } from './teacher/visionEvidenceVerifier' -import { isLlmSetupConfigurationError, streamOpenAICompatibleToolTurn } from './llm/openaiCompatibleProvider' +import { runProviderTurn } from './llm/providerRegistry' +import { isLlmSetupConfigurationError } from './llm/openaiCompatibleProvider' type TeacherProgressEmitter = (progress: TeacherRunProgress) => void type TeacherBoardImageCaptureHandler = (request: TeacherBoardImageRenderRequest) => Promise @@ -496,6 +497,7 @@ const SHELL_TASKS = new Map() const ACTIVE_TEACHER_RUNS = new Map() const MAX_TOOL_RESULT_CHARS = 18_000 const MAX_SHELL_OUTPUT_CHARS = 24_000 +const MAX_AGENT_TURNS = 12 class TeacherRunCancelledError extends Error { constructor() { @@ -551,18 +553,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 @@ -2010,11 +2000,12 @@ async function executeAgentToolCall( call: ChatToolCall, tools: Map, state: TeacherAgentSessionState -): Promise<{ toolResult: string; followupMessages: ChatMessage[] }> { +): Promise<{ ok: boolean; toolResult: string; followupMessages: ChatMessage[] }> { assertTeacherRunActive(state.context) const tool = tools.get(call.function.name) if (!tool) { return { + ok: false, toolResult: compactToolResult({ ok: false, error: `Unknown tool: ${call.function.name}` }), followupMessages: [] } @@ -2028,6 +2019,7 @@ async function executeAgentToolCall( emitToolState(state.context, state.logs, `${tool.canonicalName} 已完成`) const followupMessages = state.pendingToolMessages.splice(0) return { + ok: true, toolResult: compactToolResult({ ok: true, tool: tool.canonicalName, result }), followupMessages } @@ -2042,6 +2034,7 @@ async function executeAgentToolCall( emitToolState(state.context, state.logs, detail) state.pendingToolMessages.splice(0) return { + ok: false, toolResult: compactToolResult({ ok: false, tool: tool.canonicalName, error: String(error) }), followupMessages: [] } @@ -2088,7 +2081,7 @@ 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) @@ -2096,20 +2089,26 @@ async function runTeacherAgentSession( { role: 'system', content: agentSystemPrompt(profile.userLevel) }, initialAgentUserMessage(state) ] + const successfulAgentTools = new Set() + const executeTool = async (call: ChatToolCall) => { + const result = await executeAgentToolCall(call, toolMap, state) + if (result.ok) successfulAgentTools.add(call.function.name) + return result + } emitProgress(context, { stage: 'assistant-start', message: 'GoAgent agent 开始推理。', toolLogs: cloneToolLogs(logs) }) let finalText = '' let emittedText = '' - for (;;) { + for (let turn = 1; turn <= MAX_AGENT_TURNS; turn += 1) { assertTeacherRunActive(context) let streamedThisTurn = '' let result: ChatTurnResult try { - result = await streamOpenAICompatibleToolTurn(settings, messages, tools, 4096, (delta) => { + result = await runProviderTurn(settings, messages, tools, 4096, (delta) => { streamedThisTurn += delta emittedText += delta emitAssistantDelta(context, delta) - }, context?.signal) + }, context?.signal, executeTool) } catch (error) { if (!isCancellationError(error)) { markLlmSetupNeedsAttention(error) @@ -2117,6 +2116,8 @@ async function runTeacherAgentSession( throw error } assertTeacherRunActive(context) + for (const toolName of result.executedToolCalls ?? []) successfulAgentTools.add(toolName) + if (result.toolFollowupMessages?.length) messages.push(...result.toolFollowupMessages) if (result.toolCalls.length > 0) { messages.push({ role: 'assistant', @@ -2124,7 +2125,7 @@ async function runTeacherAgentSession( tool_calls: result.toolCalls }) for (const call of result.toolCalls) { - const { toolResult, followupMessages } = await executeAgentToolCall(call, toolMap, state) + const { toolResult, followupMessages } = await executeTool(call) messages.push({ role: 'tool', name: call.function.name, @@ -2146,16 +2147,44 @@ async function runTeacherAgentSession( } if (!finalText) { - throw new Error('LLM 未生成最终回答。') + throw new Error(`老师在 ${MAX_AGENT_TURNS} 轮内未完成分析,请缩小任务范围后重试。`) } const finalVisionEvidence = state.request.visionEvidence ?? visionEvidence + const finalVisionValidation = validateVisionEvidenceForIntent(finalVisionEvidence, intent) + if (!finalVisionValidation.ok) { + throw new Error(`棋盘图证据不完整:${finalVisionValidation.blockingIssues.join(';')}`) + } + const requiredToolGroups: Partial> = { + 'current-move': [ + ['board_captureTeachingImage'], + ['katago_analyzePosition'], + ['knowledge_matchPosition', 'knowledge_searchLocal'] + ], + 'game-review': [ + ['sgf_readGameRecord'], + ['katago_analyzeGameBatch'], + ['board_captureTeachingImage'], + ['knowledge_matchPosition', 'knowledge_searchLocal', 'knowledge_searchJoseki', 'knowledge_searchLifeDeath', 'knowledge_searchTesuji'] + ], + 'move-range': [ + ['katago_analyzeMoveRangeKeyMoves'], + ['board_captureTeachingImage'], + ['knowledge_matchPosition', 'knowledge_searchLocal', 'knowledge_searchJoseki', 'knowledge_searchLifeDeath', 'knowledge_searchTesuji'] + ] + } + const missingEvidence = (requiredToolGroups[intent] ?? []) + .filter((group) => !group.some((toolName) => successfulAgentTools.has(toolName))) + .map((group) => group.join(' / ')) + if (missingEvidence.length) { + throw new Error(`老师没有完成必要的证据工具调用:${missingEvidence.join(';')}`) + } const visionIssues = verifyVisionEvidenceMarkdown(finalText, finalVisionEvidence) if (visionIssues.some((issue) => issue.severity === 'error')) { messages.push({ role: 'assistant', content: finalText }) 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, [], 2048, (delta) => { emitAssistantDelta(context, delta) }, context?.signal) } catch (error) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 0c638f6..e7aff2b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -31,6 +31,7 @@ import type { LlmModelsListResult, LlmSettingsTestRequest, LlmSettingsTestResult, + LlmConnectionActionResult, KataGoMoveAnalysis, ReviewRequest, ReviewResult, @@ -165,6 +166,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 00b1d0b..19cb18a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -119,6 +119,12 @@ const emptyDashboard: DashboardData = { llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', llmModel: 'gpt-5-mini', + 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-mini', enabled: true }, + { id: 'chatgpt-codex', name: 'ChatGPT 登录', provider: 'codex-app-server', authMode: 'managed-login', model: '', enabled: true } + ], + llmConnectionSchemaVersion: 3, 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()) @@ -2783,7 +2798,7 @@ export function App(): ReactElement { } function ensureAiTeacherReady(): boolean { - const ready = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' + const ready = dashboard.systemProfile.llmConnection.ready && dashboard.settings.llmSetupStatus === 'verified' if (ready) return true setLlmTestMessage(t('llmSetupRequired')) setSettingsOpen(true) @@ -3067,7 +3082,7 @@ export function App(): ReactElement { await submitTeacherPromptText(prompt) } - const llmReady = dashboard.systemProfile.hasLlmApiKey && dashboard.settings.llmSetupStatus === 'verified' + const llmReady = dashboard.systemProfile.llmConnection.ready && dashboard.settings.llmSetupStatus === 'verified' const statusItems: StatusPill[] = [ { label: localizeKataGoStatus( @@ -3661,7 +3676,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 && dashboard.settings.llmSetupStatus === 'verified' return (
event.stopPropagation()}> @@ -4717,6 +4732,7 @@ function SettingsDrawer({ const [llmModelsFetched, setLlmModelsFetched] = useState(false) const [llmModelsRefreshing, setLlmModelsRefreshing] = useState(false) const [llmModelRefreshMessage, setLlmModelRefreshMessage] = useState('') + const [chatGptLoginPending, setChatGptLoginPending] = useState(false) const [selectedLlmModel, setSelectedLlmModel] = useState(dashboard.settings.llmModel) const [savedLlmApiKey, setSavedLlmApiKey] = useState('') const [showLlmApiKey, setShowLlmApiKey] = useState(false) @@ -4729,6 +4745,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 @@ -4795,7 +4814,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) @@ -4804,9 +4824,9 @@ function SettingsDrawer({ 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] + 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) { @@ -4817,7 +4837,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) @@ -4829,8 +4849,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) { @@ -4841,7 +4861,75 @@ 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 (!chatGptLoginPending || !managedLlmLogin || dashboard.systemProfile.llmConnection.ready) return + let cancelled = false + let timer: ReturnType | undefined + const poll = async (): Promise => { + try { + const updated = await window.goagent.getDashboard() + if (cancelled) return + onDashboardUpdated(updated) + if (updated.systemProfile.llmConnection.ready) { + setChatGptLoginPending(false) + await refreshLlmModels() + return + } + } catch { + // Keep the browser login flow usable across transient status failures. + } + if (!cancelled) timer = setTimeout(() => void poll(), 2000) + } + timer = setTimeout(() => void poll(), 2000) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [chatGptLoginPending, 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 { + setChatGptLoginPending(false) + setLlmModelRefreshMessage(t('chatGptChecking')) + try { + const result = await window.goagent.startChatGptLogin() + onDashboardUpdated(result.dashboard) + setChatGptLoginPending(Boolean(result.login)) + setLlmModelRefreshMessage(result.login ? t('chatGptFinishInBrowser') : t('chatGptLoggedIn')) + } catch (cause) { + setChatGptLoginPending(false) + setLlmModelRefreshMessage(String(cause)) + } + } + + async function logoutFromChatGpt(): Promise { + setChatGptLoginPending(false) + const result = await window.goagent.logoutChatGpt() + onDashboardUpdated(result.dashboard) + setLlmModelsFetched(false) + setRefreshedLlmModels([]) + } async function revealSavedLlmApiKey(): Promise { setLlmKeyMessage('') @@ -4868,7 +4956,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 && dashboard.settings.llmSetupStatus === 'verified' const katagoReady = Boolean(katagoAssets?.ready || dashboard.systemProfile.katagoReady) const voiceReady = dashboard.settings.ttsEnabled const settingsPages: Array<{ @@ -4978,6 +5066,23 @@ function SettingsDrawer({
{llmReady ? t('ready') : t('pendingConfig')} +
+ + +
+ {!managedLlmLogin ? <>