diff --git a/FEATURES.md b/FEATURES.md index d2157be..649617b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -28,9 +28,9 @@ - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 -- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 +- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。 - [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 -- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。 +- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。 - [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用有总时限的原生 Compact;并发外部 Server 对话的提问使用请求级公开 ID 映射,回答不会串到其他会话。外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 - [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。 - [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index bc0ddd1..722b891 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -599,6 +599,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供 “打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。 - 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。 +- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。 +- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。 - 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。 - “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 - “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。 diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index d41b1cf..1109e03 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -369,12 +369,22 @@ describe('ModelAgentRuntime', () => { } ) - it('performs a real minimal request when testing the connection', async () => { - const fetcher = vi.fn(async () => - Response.json({ - content: [{ type: 'text', text: 'OK' }] + it('requires generated text from a real minimal Anthropic request when testing the connection', async () => { + const fetcher = vi.fn(async (_input, init) => { + const body = JSON.parse(init?.body as string) as { + messages: Array<{ content: string }> + } + const marker = + /GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec( + body.messages[0]?.content ?? '' + )?.[0] + if (!marker) { + throw new Error('missing model test marker') + } + return Response.json({ + content: [{ type: 'text', text: marker }] }) - ) + }) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', @@ -386,12 +396,71 @@ describe('ModelAgentRuntime', () => { await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, - id: 'model' + id: 'model', + detail: expect.stringContaining('真实模型生成测试') }) const body = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as { max_tokens: number; stream: boolean } - expect(body).toMatchObject({ max_tokens: 1, stream: false }) + expect(body).toMatchObject({ max_tokens: 64, stream: false }) + }) + + it('rejects a successful HTTP response that does not contain generated test text', async () => { + const runtime = new ModelAgentRuntime({ + apiKey: 'test-key', + baseUrl: 'https://bigtoken.ai', + model: 'sonnet-5', + protocol: 'anthropic-messages', + authentication: 'api-key', + fetcher: vi.fn(async () => + Response.json({ + content: [{ type: 'text', text: 'generic health check' }] + }) + ) + }) + + await expect(runtime.testConnection()).rejects.toThrow( + '未完成真实生成测试' + ) + }) + + it('validates generated test text from an OpenAI Chat Completions response', async () => { + const fetcher = vi.fn(async (_input, init) => { + const body = JSON.parse(init?.body as string) as { + messages: Array<{ content: string }> + } + const marker = + /GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec( + body.messages[0]?.content ?? '' + )?.[0] + if (!marker) { + throw new Error('missing model test marker') + } + return Response.json({ + choices: [ + { + message: { + role: 'assistant', + content: marker + } + } + ] + }) + }) + const runtime = new ModelAgentRuntime({ + apiKey: 'test-key', + baseUrl: 'https://model.example/v1', + model: 'chat-model', + protocol: 'openai-chat-completions', + authentication: 'api-key', + fetcher + }) + + await expect(runtime.testConnection()).resolves.toMatchObject({ + available: true, + detail: expect.stringContaining('真实模型生成测试') + }) + expect(fetcher).toHaveBeenCalledOnce() }) it('uses the Anthropic messages endpoint and streams text deltas', async () => { @@ -1818,9 +1887,26 @@ describe('ModelAgentRuntime', () => { }) it('tests an OpenAI Responses connection with Responses request fields', async () => { - const fetcher = vi.fn(async () => - Response.json({ id: 'resp-test', output: [] }) - ) + const fetcher = vi.fn(async (_input, init) => { + const body = JSON.parse(init?.body as string) as { + input: string + } + const marker = + /GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(body.input)?.[0] + if (!marker) { + throw new Error('missing model test marker') + } + return Response.json({ + id: 'resp-test', + status: 'completed', + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: marker }] + } + ] + }) + }) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://api.openai.com/v1/', @@ -1832,19 +1918,21 @@ describe('ModelAgentRuntime', () => { await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, - detail: expect.stringContaining('已验证') + detail: expect.stringContaining('真实模型生成测试') }) expect(fetcher.mock.calls[0]?.[0]?.toString()).toBe( 'https://api.openai.com/v1/responses' ) expect( JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string) - ).toEqual({ + ).toMatchObject({ model: 'gpt-5', - max_output_tokens: 16, - stream: false, - input: 'Reply OK.' + max_output_tokens: 64, + stream: false }) + expect( + JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string).input + ).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u) }) it('runs approved direct-model tools and returns their results to OpenAI', async () => { @@ -4527,8 +4615,14 @@ describe('ModelAgentRuntime', () => { expect(fetcher).toHaveBeenCalledOnce() }) - it('reports image configuration checks without pretending to generate', async () => { - const fetcher = vi.fn() + it('performs and validates a real image generation when testing an image connection', async () => { + const png = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00 + ]).toString('base64') + const fetcher = vi.fn(async () => + Response.json({ data: [{ b64_json: png }] }) + ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', @@ -4543,10 +4637,18 @@ describe('ModelAgentRuntime', () => { available: true, capability: 'image-generation', detail: expect.stringContaining( - '发送提示词时执行实际生成验证' + '已完成真实图像生成测试' ) }) - expect(fetcher).not.toHaveBeenCalled() + expect(fetcher).toHaveBeenCalledOnce() + expect( + JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string) + ).toMatchObject({ + model: 'gpt-image-2', + n: 1, + quality: 'medium', + response_format: 'b64_json' + }) }) it('generates a bounded image through the BigToken-compatible endpoint', async () => { diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index e228414..5be2350 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -1640,6 +1640,18 @@ export class ModelAgentRuntime implements AgentRuntime { return headers } + private createImageGenerationRequest( + prompt: string + ): Record { + return { + model: this.options.model, + prompt: prompt.slice(0, 100_000), + n: 1, + quality: this.options.imageGenerationQuality ?? 'auto', + response_format: 'b64_json' + } + } + private async fetchWithTimeout( input: URL, init: RequestInit, @@ -1688,38 +1700,44 @@ export class ModelAgentRuntime implements AgentRuntime { if (!this.isConfigured()) { return this.getStatus() } - if (this.options.protocol === 'openai-images-generations') { - return { - ...(await this.getStatus()), - detail: `已识别图像生成配置,发送提示词时执行实际生成验证 · ${this.options.baseUrl}` - } - } + const imageGeneration = + this.options.protocol === 'openai-images-generations' + const marker = `GOODBUDDY_MODEL_TEST_${randomBytes(12) + .toString('hex') + .toUpperCase()}` + const prompt = imageGeneration + ? 'Generate a simple image of one solid blue circle centered on a white background.' + : `Reply with exactly this text and nothing else: ${marker}` const response = await this.fetcher(this.getEndpoint(), { method: 'POST', - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(60_000), headers: this.getHeaders(), body: JSON.stringify( - this.options.protocol === 'openai-responses' + imageGeneration + ? this.createImageGenerationRequest(prompt) + : this.options.protocol === 'openai-responses' ? { model: this.options.model, - max_output_tokens: 16, + max_output_tokens: 64, stream: false, - input: 'Reply OK.' + input: prompt } : { model: this.options.model, - max_tokens: 1, + max_tokens: 64, stream: false, - messages: [{ role: 'user', content: 'Reply OK.' }] + messages: [{ role: 'user', content: prompt }] } ) }) + const responseText = await readBoundedResponseText(response, { + maxBytes: response.ok && imageGeneration + ? maxImageResponseBytes + : 128 * 1024, + missingBodyMessage: '模型接口未返回响应内容', + tooLargeMessage: '模型接口响应超过安全限制' + }) if (!response.ok) { - const responseText = await readBoundedResponseText(response, { - maxBytes: 128 * 1024, - missingBodyMessage: '模型接口未返回响应内容', - tooLargeMessage: '模型接口响应超过安全限制' - }) let detail: string | undefined try { detail = getErrorMessage( @@ -1733,13 +1751,34 @@ export class ModelAgentRuntime implements AgentRuntime { `模型接口连接测试失败(HTTP ${response.status})` ) } - await response.body?.cancel().catch(() => undefined) + let payload: unknown + try { + payload = JSON.parse(responseText) + } catch { + throw new Error('模型接口返回了无效 JSON,未完成真实生成测试') + } + if (imageGeneration) { + parseGeneratedImage(payload) + } else { + const result = parseModelToolResponse( + payload, + this.options.protocol === 'anthropic-messages' + ? 'anthropic' + : this.options.protocol === 'openai-responses' + ? 'openai-responses' + : 'openai' + ) + if (!result.text.includes(marker)) { + throw new Error('模型接口未返回测试文本,未完成真实生成测试') + } + } return { - id: 'model', - label: this.options.model, + ...(await this.getStatus()), available: true, - supportsToolExecution: this.supportsToolExecution, - detail: `已验证模型接口连接 · ${this.options.baseUrl}` + detail: `${imageGeneration + ? '已完成真实图像生成测试' + : '已完成真实模型生成测试' + } · ${this.options.baseUrl}` } } @@ -2376,13 +2415,9 @@ export class ModelAgentRuntime implements AgentRuntime { type: 'status', message: `${this.options.model} 正在生成图片` } - const imageRequest = { - model: this.options.model, - prompt: request.prompt.slice(0, 100_000), - n: 1, - quality: this.options.imageGenerationQuality ?? 'auto', - response_format: 'b64_json' - } + const imageRequest = this.createImageGenerationRequest( + request.prompt + ) const modelRequest = await this.fetchWithTimeout( this.getEndpoint(), { diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index 1bbac7f..4f6faef 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -906,7 +906,7 @@ describe('RuntimeSettingsStore', () => { ).toBe(true) }) - it('encrypts an OpenAI-compatible embedding API key and binds it to the full endpoint', async () => { + it('keeps an encrypted embedding API key when its endpoint changes', async () => { const { filePath, store } = await createStore() await store.update( settings({ @@ -933,15 +933,18 @@ describe('RuntimeSettingsStore', () => { knowledgeEmbeddingApiKeyConfigured: true, knowledgeEmbeddingCredentialSource: 'encrypted' }) - await expect( - store.update( - settings({ - knowledgeEmbeddingBaseUrl: - 'https://vectors.example/v1/embeddings', - knowledgeEmbeddingApiKey: { action: 'keep' } - }) - ) - ).rejects.toThrow('重新输入或清除 API Key') + await store.update( + settings({ + knowledgeEmbeddingBaseUrl: + 'https://vectors.example/v1/embeddings', + knowledgeEmbeddingApiKey: { action: 'keep' } + }) + ) + await expect(store.getResolvedSettings()).resolves.toMatchObject({ + knowledgeEmbeddingBaseUrl: + 'https://vectors.example/v1/embeddings', + knowledgeEmbeddingApiKey: 'vector-secret-value' + }) }) it('migrates version 13 with reranking disabled by default', async () => { @@ -968,7 +971,7 @@ describe('RuntimeSettingsStore', () => { }) }) - it('encrypts and endpoint-binds the rerank API key', async () => { + it('keeps an encrypted rerank API key when its endpoint changes', async () => { const { filePath, store } = await createStore() await store.update( settings({ @@ -995,14 +998,16 @@ describe('RuntimeSettingsStore', () => { knowledgeRerankApiKeyConfigured: true, knowledgeRerankCredentialSource: 'encrypted' }) - await expect( - store.update( - settings({ - knowledgeRerankEndpoint: 'https://other.example/v1/rerank', - knowledgeRerankApiKey: { action: 'keep' } - }) - ) - ).rejects.toThrow('重排接口 URL 已更改') + await store.update( + settings({ + knowledgeRerankEndpoint: 'https://other.example/v1/rerank', + knowledgeRerankApiKey: { action: 'keep' } + }) + ) + await expect(store.getResolvedSettings()).resolves.toMatchObject({ + knowledgeRerankEndpoint: 'https://other.example/v1/rerank', + knowledgeRerankApiKey: 'rerank-secret-value' + }) }) it('prefers the rerank environment API key without exposing it', async () => { @@ -1251,7 +1256,7 @@ describe('RuntimeSettingsStore', () => { }) }) - it('encrypts the API key and binds it to the configured origin', async () => { + it('keeps an encrypted API key with its model connection when the URL changes', async () => { const { filePath, store } = await createStore() await store.update( settings({ @@ -1266,14 +1271,93 @@ describe('RuntimeSettingsStore', () => { modelBaseUrl: 'https://bigtoken.ai' }) - await expect( - store.update( - settings({ - modelBaseUrl: 'https://other.example', - apiKey: { action: 'keep' } + await store.update( + settings({ + modelBaseUrl: 'https://other.example', + apiKey: { action: 'keep' } + }) + ) + await expect(store.getResolvedSettings()).resolves.toMatchObject({ + apiKey: 'test-secret-value', + modelBaseUrl: 'https://other.example' + }) + await expect(store.getPublicSettings()).resolves.toMatchObject({ + apiKeyConfigured: true, + credentialSource: 'encrypted' + }) + }) + + it('keeps a model API key while authentication is disabled and restores it when re-enabled', async () => { + const { store } = await createStore() + await store.update( + settings({ + apiKey: { + action: 'replace', + value: 'connection-scoped-secret' + } + }) + ) + + await store.update( + settings({ + modelAuthentication: 'none', + apiKey: { action: 'keep' } + }) + ) + await expect(store.getPublicSettings()).resolves.toMatchObject({ + modelAuthentication: 'none', + modelProfiles: [ + expect.objectContaining({ + authentication: 'none', + apiKeyConfigured: true, + credentialSource: 'encrypted' }) - ) - ).rejects.toThrow('请重新输入或清除') + ] + }) + await expect(store.getResolvedSettings()).resolves.toMatchObject({ + modelAuthentication: 'none', + apiKey: undefined + }) + + await store.update( + settings({ + modelAuthentication: 'api-key', + apiKey: { action: 'keep' } + }) + ) + await expect(store.getResolvedSettings()).resolves.toMatchObject({ + modelAuthentication: 'api-key', + apiKey: 'connection-scoped-secret' + }) + }) + + it('reads a legacy API key payload after the model connection URL changes', async () => { + const { filePath, store } = await createStore() + await store.update(settings()) + const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { + modelProfiles: Array> + } + persisted.modelProfiles[0]!.baseUrl = 'https://new.example/v1' + persisted.modelProfiles[0]!.credential = { + formatVersion: 1, + scheme: 'electron-safe-storage', + ciphertextBase64: cipher + .encrypt( + JSON.stringify({ + version: 1, + apiKey: 'legacy-connection-secret', + origin: 'https://old.example' + }) + ) + .toString('base64') + } + await writeFile(filePath, JSON.stringify(persisted), 'utf8') + + const migrated = new RuntimeSettingsStore(filePath, cipher, {}) + await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ + modelBaseUrl: 'https://new.example/v1', + apiKey: 'legacy-connection-secret' + }) }) it('does not infer image capability from the model name', async () => { @@ -1473,6 +1557,59 @@ describe('RuntimeSettingsStore', () => { }) }) + it('uses the default environment key only for the default model connection', async () => { + const { filePath, store } = await createStore() + const defaultId = '00000000-0000-4000-8000-000000000031' + const secondaryId = '00000000-0000-4000-8000-000000000032' + await store.update( + settings({ + modelProfiles: [ + { + id: defaultId, + name: 'Default', + baseUrl: 'https://default.example/v1', + modelName: 'default-model', + protocol: 'openai-chat-completions', + authentication: 'api-key', + imageGenerationQuality: 'auto', + apiKey: { action: 'replace', value: 'stored-default-key' } + }, + { + id: secondaryId, + name: 'Secondary', + baseUrl: 'https://secondary.example/v1', + modelName: 'secondary-model', + protocol: 'openai-chat-completions', + authentication: 'api-key', + imageGenerationQuality: 'auto', + apiKey: { action: 'replace', value: 'secondary-key' } + } + ], + defaultModelProfileId: defaultId + }) + ) + + const environmentStore = new RuntimeSettingsStore(filePath, cipher, { + GOODBUDDY_MODEL_API_KEY: 'environment-default-key', + GOODBUDDY_MODEL_BASE_URL: 'https://environment.example/v1', + GOODBUDDY_MODEL_NAME: 'environment-model' + }) + await expect( + environmentStore.getResolvedSettings() + ).resolves.toMatchObject({ + modelProfiles: [ + expect.objectContaining({ + id: defaultId, + apiKey: 'environment-default-key' + }), + expect.objectContaining({ + id: secondaryId, + apiKey: 'secondary-key' + }) + ] + }) + }) + it('prefers generic model environment variables over legacy fallbacks', async () => { const { filePath } = await createStore() const store = new RuntimeSettingsStore(filePath, cipher, { diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index 00ad7c8..1a830d8 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -294,19 +294,38 @@ const legacyStoredSettingsSchema = z.object({ toolApproval: toolApprovalPolicySchema }) -const credentialPayloadSchema = z.object({ - version: z.literal(1), - apiKey: z.string(), - origin: z.string() +const savedApiKeyPayloadSchema = z.object({ + version: z.literal(2), + apiKey: z.string() }) -const embeddingCredentialPayloadSchema = z.object({ - version: z.literal(1), - apiKey: z.string(), - endpoint: z.string() -}) +const credentialPayloadSchema = z.union([ + savedApiKeyPayloadSchema, + z.object({ + version: z.literal(1), + apiKey: z.string(), + origin: z.string() + }) +]) + +const endpointCredentialPayloadSchema = z.union([ + savedApiKeyPayloadSchema, + z.object({ + version: z.literal(1), + apiKey: z.string(), + endpoint: z.string() + }) +]) + +const encryptSavedApiKey = ( + cipher: SettingsCredentialCipher, + apiKey: string +) => + encryptSettingsCredential(cipher, { + version: 2, + apiKey + }) -const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema const platformHarnessProfileId = 'goodbuddy-platform-harness' export type CredentialCipher = SettingsCredentialCipher @@ -365,6 +384,12 @@ export type ResolvedModelProfile = { apiKey?: string } +type ResolvedModelCredential = { + activeApiKey?: string + configured: boolean + source: RuntimeSettings['credentialSource'] +} + const defaultSettings: StoredSettings = { version: 18, provider: defaultRuntimeSettings.provider, @@ -1031,14 +1056,8 @@ export class RuntimeSettingsStore { const payload = credentialPayloadSchema.parse( decryptSettingsCredential(this.cipher, profile.credential) ) - if (payload.origin !== new URL(profile.baseUrl).origin) { - return warning('runtime-model-credential-binding-mismatch') - } this.removeWarnings( - [ - 'runtime-model-credential-unreadable', - 'runtime-model-credential-binding-mismatch' - ], + ['runtime-model-credential-unreadable'], profile.name ) return payload.apiKey @@ -1060,22 +1079,13 @@ export class RuntimeSettingsStore { return undefined } try { - const payload = embeddingCredentialPayloadSchema.parse( + const payload = endpointCredentialPayloadSchema.parse( decryptSettingsCredential( this.cipher, settings.knowledgeEmbeddingCredential ) ) - if (payload.endpoint !== settings.knowledgeEmbeddingBaseUrl) { - this.addWarning({ - code: 'runtime-embedding-credential-binding-mismatch' - }) - return undefined - } - this.removeWarnings([ - 'runtime-embedding-credential-unreadable', - 'runtime-embedding-credential-binding-mismatch' - ]) + this.removeWarnings(['runtime-embedding-credential-unreadable']) return payload.apiKey } catch { this.addWarning({ @@ -1098,22 +1108,13 @@ export class RuntimeSettingsStore { return undefined } try { - const payload = rerankCredentialPayloadSchema.parse( + const payload = endpointCredentialPayloadSchema.parse( decryptSettingsCredential( this.cipher, settings.knowledgeRerankCredential ) ) - if (payload.endpoint !== settings.knowledgeRerankEndpoint) { - this.addWarning({ - code: 'runtime-rerank-credential-binding-mismatch' - }) - return undefined - } - this.removeWarnings([ - 'runtime-rerank-credential-unreadable', - 'runtime-rerank-credential-binding-mismatch' - ]) + this.removeWarnings(['runtime-rerank-credential-unreadable']) return payload.apiKey } catch { this.addWarning({ @@ -1183,7 +1184,54 @@ export class RuntimeSettingsStore { } } - private resolveEffectiveModelSettings(settings: StoredSettings): { + private resolveModelCredentials( + settings: StoredSettings + ): Map { + const defaultProfile = + settings.modelProfiles.find( + (profile) => profile.id === settings.defaultModelProfileId + ) ?? settings.modelProfiles[0] + const environmentApiKey = + defaultProfile?.authentication === 'api-key' + ? this.getEnvironmentApiKey() + : undefined + return new Map( + settings.modelProfiles.map((profile) => { + const environmentManaged = + profile.id === defaultProfile?.id && + Boolean(environmentApiKey) + const storedApiKey = environmentManaged + ? undefined + : this.getStoredApiKey(profile) + const source: RuntimeSettings['credentialSource'] = + environmentManaged + ? 'environment' + : storedApiKey + ? 'encrypted' + : profile.credential + ? 'unreadable' + : 'none' + return [ + profile.id, + { + activeApiKey: + profile.authentication === 'api-key' + ? environmentManaged + ? environmentApiKey + : storedApiKey + : undefined, + configured: environmentManaged || Boolean(storedApiKey), + source + } + ] + }) + ) + } + + private resolveEffectiveModelSettings( + settings: StoredSettings, + credentials: ReadonlyMap + ): { apiKey?: string baseUrl: string model: string @@ -1201,36 +1249,25 @@ export class RuntimeSettingsStore { if (!profile) { throw new Error('默认模型连接不存在') } - const environmentApiKey = - profile.authentication === 'api-key' - ? this.getEnvironmentApiKey() - : undefined - const storedApiKey = - profile.authentication === 'api-key' && !environmentApiKey - ? this.getStoredApiKey(profile) - : undefined + const credential = credentials.get(profile.id) + if (!credential) { + throw new Error(`模型连接不存在:${profile.id}`) + } + const environmentManaged = credential.source === 'environment' const environmentBaseUrl = this.environment.GOODBUDDY_MODEL_BASE_URL?.trim() || this.environment.GOODBUDDY_BIGTOKEN_BASE_URL?.trim() const environmentModel = this.environment.GOODBUDDY_MODEL_NAME?.trim() || this.environment.GOODBUDDY_BIGTOKEN_MODEL?.trim() - const baseUrl = environmentApiKey + const baseUrl = environmentManaged ? environmentBaseUrl || defaultRuntimeSettings.modelBaseUrl : profile.baseUrl - const model = environmentApiKey + const model = environmentManaged ? environmentModel || defaultRuntimeSettings.modelName : profile.modelName - const credentialSource: RuntimeSettings['credentialSource'] = - environmentApiKey - ? 'environment' - : storedApiKey - ? 'encrypted' - : profile.credential - ? 'unreadable' - : 'none' return { - apiKey: environmentApiKey ?? storedApiKey, + apiKey: credential.activeApiKey, baseUrl, model, protocol: profile.protocol, @@ -1238,7 +1275,7 @@ export class RuntimeSettingsStore { supportsImageInput: profile.supportsImageInput, contextWindowTokens: profile.contextWindowTokens, imageGenerationQuality: profile.imageGenerationQuality, - credentialSource + credentialSource: credential.source } } @@ -1246,39 +1283,41 @@ export class RuntimeSettingsStore { settings: StoredSettings, effective: ReturnType< RuntimeSettingsStore['resolveEffectiveModelSettings'] - > + >, + credentials: ReadonlyMap ): ResolvedModelProfile[] { - return settings.modelProfiles.map((profile) => - profile.id === settings.defaultModelProfileId - ? { - id: profile.id, - name: profile.name, - baseUrl: effective.baseUrl, - modelName: effective.model, - protocol: effective.protocol, - authentication: effective.authentication, - supportsImageInput: effective.supportsImageInput, - contextWindowTokens: effective.contextWindowTokens, - imageGenerationQuality: - effective.imageGenerationQuality, - apiKey: effective.apiKey - } - : { - id: profile.id, - name: profile.name, - baseUrl: profile.baseUrl, - modelName: profile.modelName, - protocol: profile.protocol, - authentication: profile.authentication, - supportsImageInput: profile.supportsImageInput, - contextWindowTokens: profile.contextWindowTokens, - imageGenerationQuality: profile.imageGenerationQuality, - apiKey: - profile.authentication === 'api-key' - ? this.getStoredApiKey(profile) - : undefined - } - ) + return settings.modelProfiles.map((profile) => { + if (profile.id === settings.defaultModelProfileId) { + return { + id: profile.id, + name: profile.name, + baseUrl: effective.baseUrl, + modelName: effective.model, + protocol: effective.protocol, + authentication: effective.authentication, + supportsImageInput: effective.supportsImageInput, + contextWindowTokens: effective.contextWindowTokens, + imageGenerationQuality: effective.imageGenerationQuality, + apiKey: effective.apiKey + } + } + const credential = credentials.get(profile.id) + if (!credential) { + throw new Error(`模型连接不存在:${profile.id}`) + } + return { + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + modelName: profile.modelName, + protocol: profile.protocol, + authentication: profile.authentication, + supportsImageInput: profile.supportsImageInput, + contextWindowTokens: profile.contextWindowTokens, + imageGenerationQuality: profile.imageGenerationQuality, + apiKey: credential.activeApiKey + } + }) } private resolveAgentSettings(settings: StoredSettings): { @@ -1325,25 +1364,29 @@ export class RuntimeSettingsStore { } private toPublicSettings(settings: StoredSettings): RuntimeSettings { - const effective = this.resolveEffectiveModelSettings(settings) - const agent = this.resolveAgentSettings(settings) - const environmentApiKeyConfigured = Boolean( - this.getEnvironmentApiKey() + const credentials = this.resolveModelCredentials(settings) + const effective = this.resolveEffectiveModelSettings( + settings, + credentials ) + const agent = this.resolveAgentSettings(settings) const resolvedModelProfiles = this.resolveModelProfiles( settings, - effective + effective, + credentials ) const resolvedProfilesById = new Map( resolvedModelProfiles.map((profile) => [profile.id, profile]) ) const modelProfiles = settings.modelProfiles.map((profile) => { - const isDefault = profile.id === settings.defaultModelProfileId const resolved = resolvedProfilesById.get(profile.id) if (!resolved) { throw new Error(`模型连接不存在:${profile.id}`) } - const apiKey = resolved.apiKey + const credential = credentials.get(profile.id) + if (!credential) { + throw new Error(`模型连接不存在:${profile.id}`) + } return { id: profile.id, name: profile.name, @@ -1356,22 +1399,15 @@ export class RuntimeSettingsStore { imageGenerationQuality: resolved.imageGenerationQuality ?? defaultRuntimeSettings.imageGenerationQuality, - apiKeyConfigured: Boolean(apiKey), - credentialSource: isDefault - ? effective.credentialSource - : apiKey - ? ('encrypted' as const) - : profile.credential - ? ('unreadable' as const) - : ('none' as const) + apiKeyConfigured: credential.configured, + credentialSource: credential.source } }) const configuredModelProfiles = settings.modelProfiles.map((profile) => { - const environmentManaged = - profile.id === settings.defaultModelProfileId && - profile.authentication === 'api-key' && - environmentApiKeyConfigured - const apiKey = resolvedProfilesById.get(profile.id)?.apiKey + const credential = credentials.get(profile.id) + if (!credential) { + throw new Error(`模型连接不存在:${profile.id}`) + } return { id: profile.id, name: profile.name, @@ -1384,14 +1420,8 @@ export class RuntimeSettingsStore { imageGenerationQuality: profile.imageGenerationQuality ?? defaultRuntimeSettings.imageGenerationQuality, - apiKeyConfigured: environmentManaged || Boolean(apiKey), - credentialSource: environmentManaged - ? ('environment' as const) - : apiKey - ? ('encrypted' as const) - : profile.credential - ? ('unreadable' as const) - : ('none' as const) + apiKeyConfigured: credential.configured, + credentialSource: credential.source } }) const embeddingEnvironmentApiKey = @@ -1529,9 +1559,17 @@ export class RuntimeSettingsStore { async getResolvedSettings(): Promise { const settings = await this.load() - const effective = this.resolveEffectiveModelSettings(settings) + const credentials = this.resolveModelCredentials(settings) + const effective = this.resolveEffectiveModelSettings( + settings, + credentials + ) const agent = this.resolveAgentSettings(settings) - const modelProfiles = this.resolveModelProfiles(settings, effective) + const modelProfiles = this.resolveModelProfiles( + settings, + effective, + credentials + ) const profilesById = new Map( modelProfiles.map((profile) => [profile.id, profile]) ) @@ -1664,17 +1702,6 @@ export class RuntimeSettingsStore { const normalizedBaseUrl = normalizeModelBaseUrl( environmentManaged ? existing.baseUrl : profile.baseUrl ) - if ( - profile.authentication === 'api-key' && - profile.apiKey.action === 'keep' && - existing?.credential && - new URL(existing.baseUrl).origin !== - new URL(normalizedBaseUrl).origin - ) { - throw new Error( - `模型连接“${profile.name}”的服务地址已更改,请重新输入或清除 API Key` - ) - } const nextProfile: StoredSettings['modelProfiles'][number] = { id: profile.id, name: profile.name, @@ -1689,7 +1716,6 @@ export class RuntimeSettingsStore { imageGenerationQuality: profile.imageGenerationQuality } if ( - profile.authentication === 'api-key' && profile.apiKey.action === 'keep' && existing?.credential ) { @@ -1698,13 +1724,9 @@ export class RuntimeSettingsStore { profile.authentication === 'api-key' && profile.apiKey.action === 'replace' ) { - nextProfile.credential = encryptSettingsCredential( + nextProfile.credential = encryptSavedApiKey( this.cipher, - { - version: 1, - apiKey: profile.apiKey.value, - origin: new URL(normalizedBaseUrl).origin - } + profile.apiKey.value ) } return nextProfile @@ -1715,15 +1737,6 @@ export class RuntimeSettingsStore { ).toString() const embeddingApiKeyUpdate = input.knowledgeEmbeddingApiKey ?? { action: 'keep' as const } - if ( - embeddingApiKeyUpdate.action === 'keep' && - current.knowledgeEmbeddingCredential && - current.knowledgeEmbeddingBaseUrl !== embeddingEndpoint - ) { - throw new Error( - '向量接口 URL 已更改,请重新输入或清除 API Key' - ) - } let knowledgeEmbeddingCredential: StoredSettings['knowledgeEmbeddingCredential'] if ( embeddingApiKeyUpdate.action === 'keep' && @@ -1732,13 +1745,9 @@ export class RuntimeSettingsStore { knowledgeEmbeddingCredential = current.knowledgeEmbeddingCredential } else if (embeddingApiKeyUpdate.action === 'replace') { - knowledgeEmbeddingCredential = encryptSettingsCredential( + knowledgeEmbeddingCredential = encryptSavedApiKey( this.cipher, - { - version: 1, - apiKey: embeddingApiKeyUpdate.value, - endpoint: embeddingEndpoint - } + embeddingApiKeyUpdate.value ) } @@ -1747,15 +1756,6 @@ export class RuntimeSettingsStore { ).toString() const rerankApiKeyUpdate = input.knowledgeRerankApiKey ?? { action: 'keep' as const } - if ( - rerankApiKeyUpdate.action === 'keep' && - current.knowledgeRerankCredential && - current.knowledgeRerankEndpoint !== rerankEndpoint - ) { - throw new Error( - '重排接口 URL 已更改,请重新输入或清除 API Key' - ) - } let knowledgeRerankCredential: StoredSettings['knowledgeRerankCredential'] if ( rerankApiKeyUpdate.action === 'keep' && @@ -1763,13 +1763,9 @@ export class RuntimeSettingsStore { ) { knowledgeRerankCredential = current.knowledgeRerankCredential } else if (rerankApiKeyUpdate.action === 'replace') { - knowledgeRerankCredential = encryptSettingsCredential( + knowledgeRerankCredential = encryptSavedApiKey( this.cipher, - { - version: 1, - apiKey: rerankApiKeyUpdate.value, - endpoint: rerankEndpoint - } + rerankApiKeyUpdate.value ) } diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 328bda8..e38141f 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -3402,11 +3402,119 @@ describe('SettingsPanel runtime files', () => { await waitFor(() => expect(onNotify).toHaveBeenCalledWith({ tone: 'success', - message: '连接成功:sonnet-5', + message: '真实生成测试通过:sonnet-5', dedupeKey: 'model-connection-tested' }) ) - expect(screen.queryByText('连接成功:sonnet-5')).not.toBeInTheDocument() + expect( + screen.queryByText('真实生成测试通过:sonnet-5') + ).not.toBeInTheDocument() + }) + + it('places the API Key directly after authentication and keeps it when the model URL changes', async () => { + getRuntime.mockResolvedValueOnce({ + ...runtimeSettings, + apiKeyConfigured: true, + credentialSource: 'encrypted', + modelProfiles: [ + { + ...runtimeSettings.modelProfiles[0]!, + apiKeyConfigured: true, + credentialSource: 'encrypted' + } + ] + }) + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: '模型连接' })) + await screen.findByDisplayValue('默认模型') + const authentication = screen.getByLabelText( + '认证方式 默认模型' + ) + const apiKey = screen.getByLabelText('API Key') + expect( + authentication.closest('label')?.nextElementSibling + ).toBe(apiKey.closest('label')) + expect(apiKey).toHaveAttribute( + 'placeholder', + '已配置,留空保持不变' + ) + + fireEvent.change(screen.getByDisplayValue('https://bigtoken.ai'), { + target: { value: 'https://new-model.example/v1' } + }) + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + + await waitFor(() => + expect(updateRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + modelProfiles: [ + expect.objectContaining({ + baseUrl: 'https://new-model.example/v1', + apiKey: { action: 'keep' } + }) + ] + }) + ) + ) + }) + + it('does not clear a saved API Key when authentication is temporarily disabled', async () => { + getRuntime.mockResolvedValueOnce({ + ...runtimeSettings, + apiKeyConfigured: true, + credentialSource: 'encrypted', + modelProfiles: [ + { + ...runtimeSettings.modelProfiles[0]!, + apiKeyConfigured: true, + credentialSource: 'encrypted' + } + ] + }) + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: '模型连接' })) + await screen.findByDisplayValue('默认模型') + fireEvent.change(screen.getByLabelText('认证方式 默认模型'), { + target: { value: 'none' } + }) + expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument() + expect( + screen.getByText( + '当前无需认证;已保存的 API Key 仍保留在此连接中' + ) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + + await waitFor(() => + expect(updateRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + modelProfiles: [ + expect.objectContaining({ + authentication: 'none', + apiKey: { action: 'keep' } + }) + ] + }) + ) + ) }) it('shows an actionable model error without Electron IPC prefixes', async () => { diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx index ffe8f3a..6105f23 100644 --- a/src/renderer/src/SettingsPanel.tsx +++ b/src/renderer/src/SettingsPanel.tsx @@ -1138,7 +1138,8 @@ export function SettingsPanel({ imageGenerationQuality: profile.imageGenerationQuality, apiKey: profile.clearApiKey ? ({ action: 'clear' } as const) - : profile.apiKey.trim() + : profile.authentication === 'api-key' && + profile.apiKey.trim() ? ({ action: 'replace', value: profile.apiKey.trim() @@ -1295,12 +1296,13 @@ export function SettingsPanel({ } onNotify({ tone: 'success', - message: - status.capability === 'image-generation' - ? status.detail - : t('notifications.connectionSucceeded', { - label: status.label - }), + message: testingModel + ? t('notifications.modelGenerationTestSucceeded', { + label: status.label + }) + : t('notifications.connectionSucceeded', { + label: status.label + }), dedupeKey: testingModel ? 'model-connection-tested' : `runtime-connection-tested-${agentRuntimeType}` @@ -2765,11 +2767,7 @@ export function SettingsPanel({ const authentication = event.target .value as ModelProfileDraft['authentication'] updateModelProfile(profile.id, { - authentication, - apiKey: '', - clearApiKey: - authentication === 'none' && - profile.apiKeyConfigured + authentication }) }} value={profile.authentication} @@ -2780,6 +2778,67 @@ export function SettingsPanel({ + {profile.authentication === 'api-key' ? ( + <> + +
+ + + {t( + `credentials.${profile.credentialSource}` + )} + + {profile.credentialSource === 'encrypted' && ( + + )} +
+ + ) : ( +
+ + + {profile.apiKeyConfigured + ? t('credentials.savedForConnection') + : t( + 'credentials.noAuthenticationDescription' + )} + +
+ )} {isAgentRuntimeModelProtocol(profile.protocol) && ( <>
@@ -2864,63 +2923,6 @@ export function SettingsPanel({ )} - {profile.authentication === 'api-key' ? ( - <> - -
- - - {t( - `credentials.${profile.credentialSource}` - )} - - {profile.credentialSource === 'encrypted' && ( - - )} -
- - ) : ( -
- - - {t('credentials.noAuthenticationDescription')} - -
- )} {t('model.profile.compatibilitySummary', { directCapability: diff --git a/src/renderer/src/i18n/locales/en-US/settings.ts b/src/renderer/src/i18n/locales/en-US/settings.ts index 03e3345..90d8591 100644 --- a/src/renderer/src/i18n/locales/en-US/settings.ts +++ b/src/renderer/src/i18n/locales/en-US/settings.ts @@ -147,6 +147,8 @@ export const settings = { notifications: { settingsSaved: 'Settings saved', connectionSucceeded: 'Connected: {{label}}', + modelGenerationTestSucceeded: + 'Real generation test passed: {{label}}', documentParsingSaved: 'Document parsing settings saved', documentParsingTestSucceeded: 'Document parsing test completed' }, @@ -159,7 +161,9 @@ export const settings = { enterApiKey: 'Enter API Key', noAuthentication: 'No authentication', noAuthenticationDescription: - 'No authentication is required; no API Key will be sent' + 'No authentication is required; no API Key will be sent', + savedForConnection: + 'Authentication is currently disabled; the saved API Key remains stored with this connection' }, runtime: { configCard: { @@ -684,7 +688,7 @@ export const settings = { generatedName: 'Model connection {{count}}', title: 'LLM model connections', description: - 'Supports OpenAI Responses, Anthropic Messages, and OpenAI-compatible Chat Completions. Image models use the separate OpenAI Images Generations protocol.', + 'Supports OpenAI Responses, Anthropic Messages, and OpenAI-compatible Chat Completions. Image models use the separate OpenAI Images Generations protocol. “Save and test model” sends a real text or image generation request and may incur provider usage charges.', listAriaLabel: 'Model connection list', listTitle: 'Connections', editAriaLabel: 'Edit model connection {{name}}', diff --git a/src/renderer/src/i18n/locales/en-US/warnings.ts b/src/renderer/src/i18n/locales/en-US/warnings.ts index 4cb2ebe..fbc6423 100644 --- a/src/renderer/src/i18n/locales/en-US/warnings.ts +++ b/src/renderer/src/i18n/locales/en-US/warnings.ts @@ -12,16 +12,10 @@ export const warnings = { 'The Runtime settings file was corrupt. The original file was isolated, and defaults are now in use.', 'runtime-model-credential-unreadable': 'The API Key for model connection “{{subject}}” cannot be read. Re-enter or clear this credential.', - 'runtime-model-credential-binding-mismatch': - 'The service address for model connection “{{subject}}” does not match its saved API Key. Re-enter or clear this credential.', 'runtime-embedding-credential-unreadable': 'The embedding model API Key cannot be read. Re-enter or clear this credential.', - 'runtime-embedding-credential-binding-mismatch': - 'The embedding endpoint does not match its saved API Key. Re-enter or clear this credential.', 'runtime-rerank-credential-unreadable': 'The rerank model API Key cannot be read. Re-enter or clear this credential.', - 'runtime-rerank-credential-binding-mismatch': - 'The rerank endpoint does not match its saved API Key. Re-enter or clear this credential.', 'channel-settings-recovered': 'The channel settings file was corrupt. The original file was isolated, and all channels were restored as disabled.', 'channel-weixin-credential-unreadable': diff --git a/src/renderer/src/i18n/locales/zh-CN/settings.ts b/src/renderer/src/i18n/locales/zh-CN/settings.ts index b37b9b6..a104e19 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settings.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settings.ts @@ -129,6 +129,7 @@ export const settings = { notifications: { settingsSaved: '设置已保存', connectionSucceeded: '连接成功:{{label}}', + modelGenerationTestSucceeded: '真实生成测试通过:{{label}}', documentParsingSaved: '文档解析设置已保存', documentParsingTestSucceeded: '文档解析测试完成' }, @@ -140,7 +141,9 @@ export const settings = { configuredPlaceholder: '已配置,留空保持不变', enterApiKey: '输入 API Key', noAuthentication: '无需认证', - noAuthenticationDescription: '无需认证,不会发送 API Key' + noAuthenticationDescription: '无需认证,不会发送 API Key', + savedForConnection: + '当前无需认证;已保存的 API Key 仍保留在此连接中' }, runtime: { configCard: { @@ -628,7 +631,7 @@ export const settings = { generatedName: '模型连接 {{count}}', title: 'LLM 模型连接', description: - '支持 OpenAI Responses、Anthropic Messages 和 OpenAI 兼容 Chat Completions;图片模型使用独立的 OpenAI Images Generations 接口类型', + '支持 OpenAI Responses、Anthropic Messages 和 OpenAI 兼容 Chat Completions;图片模型使用独立的 OpenAI Images Generations 接口类型。“保存并测试模型”会发送一次真实文本或图片生成请求,可能产生服务商用量费用。', listAriaLabel: '模型连接列表', listTitle: '连接列表', editAriaLabel: '编辑模型连接 {{name}}', diff --git a/src/renderer/src/i18n/locales/zh-CN/warnings.ts b/src/renderer/src/i18n/locales/zh-CN/warnings.ts index 91b2d15..558127a 100644 --- a/src/renderer/src/i18n/locales/zh-CN/warnings.ts +++ b/src/renderer/src/i18n/locales/zh-CN/warnings.ts @@ -9,16 +9,10 @@ export const warnings = { 'Runtime 设置文件已损坏。原文件已隔离,当前使用默认设置。', 'runtime-model-credential-unreadable': '模型连接“{{subject}}”的 API Key 无法读取。请重新输入或清除该凭据。', - 'runtime-model-credential-binding-mismatch': - '模型连接“{{subject}}”的服务地址与已保存 API Key 不匹配。请重新输入或清除该凭据。', 'runtime-embedding-credential-unreadable': '向量模型 API Key 无法读取。请重新输入或清除该凭据。', - 'runtime-embedding-credential-binding-mismatch': - '向量接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。', 'runtime-rerank-credential-unreadable': '重排模型 API Key 无法读取。请重新输入或清除该凭据。', - 'runtime-rerank-credential-binding-mismatch': - '重排接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。', 'channel-settings-recovered': '通道设置文件已损坏。原文件已隔离,所有通道已恢复为关闭状态。', 'channel-weixin-credential-unreadable': diff --git a/src/renderer/src/settings-warnings.ts b/src/renderer/src/settings-warnings.ts index 7148838..3162094 100644 --- a/src/renderer/src/settings-warnings.ts +++ b/src/renderer/src/settings-warnings.ts @@ -7,7 +7,6 @@ export function translateSettingsWarning( ): string { switch (warning.code) { case 'runtime-model-credential-unreadable': - case 'runtime-model-credential-binding-mismatch': return t(warning.code, { subject: warning.subject ?? '' }) diff --git a/src/shared/settings-warning-contracts.ts b/src/shared/settings-warning-contracts.ts index b23ddde..fa3b355 100644 --- a/src/shared/settings-warning-contracts.ts +++ b/src/shared/settings-warning-contracts.ts @@ -6,11 +6,8 @@ export const settingsWarningCodeSchema = z.enum([ 'capability-settings-recovered', 'runtime-settings-recovered', 'runtime-model-credential-unreadable', - 'runtime-model-credential-binding-mismatch', 'runtime-embedding-credential-unreadable', - 'runtime-embedding-credential-binding-mismatch', 'runtime-rerank-credential-unreadable', - 'runtime-rerank-credential-binding-mismatch', 'channel-settings-recovered', 'channel-weixin-credential-unreadable', 'channel-weixin-secure-storage-unavailable',