From 80526c57bf7655a7aa674650d9a6821098f775c4 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 16 Aug 2026 19:31:49 +0800 Subject: [PATCH] fix: streamline settings and context status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings Center navigation was hard to read and constrained form content, while Runtime customization duplicated headings and save actions and could lose drafts. Settings now uses readable navigation and wider content, saves base and native Runtime settings together, protects drafts, and presents one compact capabilities and defaults section. Conversation context meters could retain display-only thresholds from old Runtime settings. They now persist only measured usage, derive compression lines from current Runtime and model settings, and normalize legacy snapshots when loading them. The static website now uses the project GitHub Pages canonical URL and includes a validated Pages deployment workflow. Release note: 优化设置中心和 Agent Runtime 配置流程,避免重复标题、重复保存和未保存定制丢失;压缩线会随当前设置即时更新,官网也可通过 GitHub Pages 自动部署。 --- .github/workflows/pages.yml | 47 ++++ FEATURES.md | 2 +- UI-DESIGN.md | 5 +- package.json | 2 +- sites/README.md | 11 + sites/index.html | 1 + sites/scripts/validate.mjs | 6 + src/main/assistant/assistant-database.test.ts | 49 +++- src/renderer/src/App.test.tsx | 223 ++++++++++++++- src/renderer/src/App.tsx | 121 ++++---- .../src/RuntimeCustomizationSection.tsx | 259 ++++++++++++------ src/renderer/src/SettingsPanel.test.tsx | 185 +++++++++++-- src/renderer/src/SettingsPanel.tsx | 87 +++--- src/renderer/src/WorkspacePrimitives.test.tsx | 39 +++ .../src/i18n/locales/en-US/settings.ts | 57 ++-- .../src/i18n/locales/zh-CN/settings.ts | 57 ++-- src/renderer/src/styles.css | 91 ++++-- src/shared/assistant-contracts.ts | 17 +- 18 files changed, 953 insertions(+), 306 deletions(-) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..b731080 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,47 @@ +name: Deploy website to GitHub Pages + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'sites/**' + - '.github/workflows/pages.yml' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + name: Deploy static website + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v7 + + - name: Validate website + run: | + node sites/scripts/validate.mjs + node --check sites/app.js + + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Upload website artifact + uses: actions/upload-pages-artifact@v4 + with: + path: sites + + - name: Deploy website + id: deployment + uses: actions/deploy-pages@v4 diff --git a/FEATURES.md b/FEATURES.md index 3969de6..475eb69 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -27,7 +27,7 @@ - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 -- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 +- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 - [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;外部 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 原生会话管理继续暂缓。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index ec7c479..c4d97c2 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -523,13 +523,16 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 ### 13.7 设置中心 - 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。 +- 左侧分类标题使用正文级字号,分类说明使用辅助字号;右侧内容区在可用空间内流式伸缩,最大宽度使用 `standard` 壳层的 `960px`,不得以页面专属较窄宽度压缩表单。 - 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。 - 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。 - 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。 - 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。 - 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。 - 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。 -- Agent Runtime 页面在低层程序与配置覆盖之外提供“Runtime 原生定制”。原生清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 +- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 +- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 +- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。 - OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。 - MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。 diff --git a/package.json b/package.json index 117b25c..d076249 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", - "homepage": "https://github.com/mesalogo/goodbuddy", + "homepage": "https://mesalogo.github.io/goodbuddy/", "author": { "name": "MesaLogo" }, diff --git a/sites/README.md b/sites/README.md index 09f1822..8ba2664 100644 --- a/sites/README.md +++ b/sites/README.md @@ -2,6 +2,17 @@ `sites` 是无需构建步骤或额外依赖的静态官网源码,可直接托管整个目录。 +正式站点地址: + +## 部署 + +`.github/workflows/pages.yml` 会在 `main` 分支中的官网文件发生变化后, +校验并部署整个 `sites` 目录。工作流也支持在 GitHub Actions 中手动运行。 + +首次部署前,需要在 GitHub 仓库的 **Settings > Pages** 中将 **Source** +设为 **GitHub Actions**。站点使用项目 Pages 地址,不需要 `CNAME` 文件 +或自定义域名 DNS 配置。 + ## 本地预览 在仓库根目录运行: diff --git a/sites/index.html b/sites/index.html index e9f19e0..413cd15 100644 --- a/sites/index.html +++ b/sites/index.html @@ -7,6 +7,7 @@ name="description" content="GoodBuddy 是桌面 AI 助手,支持项目知识库、魔法笔记、远程消息通道和受控工具执行。" /> + GoodBuddy|桌面 AI 助手 diff --git a/sites/scripts/validate.mjs b/sites/scripts/validate.mjs index fdc71df..d69578e 100644 --- a/sites/scripts/validate.mjs +++ b/sites/scripts/validate.mjs @@ -56,6 +56,12 @@ for (const [relativePath, content] of [ report(//.test(html), "页面语言必须是 zh-CN"); report(//.test( + html, + ), + "canonical 地址必须指向 GitHub Pages 正式站点", +); report((html.match(/]/g) ?? []).length === 1, "页面必须且只能包含一个 h1"); report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接"); report(//.test(html), "缺少 main-content 主区域"); diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 963d94f..106ed4f 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -1466,9 +1466,6 @@ describe('AssistantDatabase', () => { contextMetrics: { runtimeSelectionKey: `model:${channelDefaultProfileId}`, contextTokens: 9_000, - effectiveTriggerTokens: 20_000, - contextWindowTokens: 32_000, - compressionEnabled: true, source: 'estimated' as const, basis: 'conversation' as const }, @@ -1574,6 +1571,26 @@ describe('AssistantDatabase', () => { ) const durable = new DatabaseSync(databasePath) + const contextStateRow = durable + .prepare( + `SELECT context_state_json + FROM conversations + WHERE id = ?` + ) + .get(conversationId) as { + context_state_json: string + } + const contextState = JSON.parse( + contextStateRow.context_state_json + ) as { + contextMetrics?: unknown + } + expect(contextState.contextMetrics).toEqual({ + runtimeSelectionKey: `model:${channelDefaultProfileId}`, + contextTokens: 9_000, + source: 'estimated', + basis: 'conversation' + }) expect( durable .prepare( @@ -1595,6 +1612,32 @@ describe('AssistantDatabase', () => { request_id: null } ]) + durable + .prepare( + `UPDATE conversations + SET context_state_json = ? + WHERE id = ?` + ) + .run( + JSON.stringify({ + contextMetrics: { + runtimeSelectionKey: `model:${channelDefaultProfileId}`, + contextTokens: 9_000, + effectiveTriggerTokens: 20_000, + contextWindowTokens: 32_000, + compressionEnabled: true, + source: 'estimated', + basis: 'conversation' + } + }), + conversationId + ) + expect(database.getConversation(conversationId).contextMetrics).toEqual({ + runtimeSelectionKey: `model:${channelDefaultProfileId}`, + contextTokens: 9_000, + source: 'estimated', + basis: 'conversation' + }) durable.close() database.close() }) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 9533694..7f2677f 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -2607,7 +2607,7 @@ describe('App', () => { })), contextCompression: { enabled: true, - triggerTokens: 20_000, + triggerTokens: 12_000, recentRawTokens: 4_000, modelSource: { kind: 'current' }, summaryPrompt: 'Preserve important facts.' @@ -2636,7 +2636,7 @@ describe('App', () => { }) expect( - screen.getByText('本次调用 22.0K · 压缩线 20.0K') + screen.getByText('本次调用 22.0K · 压缩线 12.0K') ).toBeInTheDocument() expect( screen.queryByRole('progressbar', { @@ -2661,14 +2661,99 @@ describe('App', () => { expect( screen.getByText( - '压缩后对话估算 ≈9.4K · 压缩线 20.0K' + '压缩后对话估算 ≈9.4K · 压缩线 12.0K' ) ).toBeInTheDocument() expect( - screen.queryByText('本次调用 22.0K · 压缩线 20.0K') + screen.queryByText('本次调用 22.0K · 压缩线 12.0K') ).not.toBeInTheDocument() }) + it('updates the composer compression line immediately after settings change', async () => { + const settings = await api.settings.getRuntime() + const initialSettings = { + ...settings, + provider: 'model' as const, + modelProfiles: settings.modelProfiles.map((profile) => ({ + ...profile, + contextWindowTokens: undefined + })), + contextCompression: { + enabled: true, + triggerTokens: 20_000, + recentRawTokens: 4_000, + modelSource: { kind: 'current' as const }, + summaryPrompt: 'Preserve important facts.' + } + } + vi.mocked(api.settings.getRuntime) + .mockResolvedValueOnce(initialSettings) + .mockResolvedValueOnce(initialSettings) + vi.mocked(api.settings.updateRuntime).mockImplementationOnce( + async (input) => ({ + ...initialSettings, + contextCompression: + input.contextCompression ?? + initialSettings.contextCompression + }) + ) + render() + + fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), { + target: { value: '检查压缩线设置刷新' } + }) + fireEvent.click(screen.getByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + const request = run.mock.calls[0]?.[0] + if (!request) { + throw new Error('Missing request') + } + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'context-metrics', + contextTokens: 9_000, + effectiveTriggerTokens: 20_000, + compressionEnabled: true, + source: 'provider' + }) + }) + expect( + screen.getByText('本次调用 9.0K · 压缩线 20.0K') + ).toBeInTheDocument() + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'done' + }) + }) + + fireEvent.click( + screen.getByRole('button', { name: /本地工作区/u }) + ) + await screen.findByRole('heading', { name: '设置中心' }) + fireEvent.click( + screen.getByRole('tab', { name: '上下文控制' }) + ) + const trigger = await screen.findByLabelText('压缩触发阈值') + fireEvent.change(trigger, { target: { value: '12' } }) + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + await waitFor(() => + expect(api.settings.updateRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + contextCompression: expect.objectContaining({ + triggerTokens: 12_000 + }) + }) + ) + ) + + fireEvent.click(screen.getByRole('button', { name: '对话' })) + expect( + await screen.findByText('本次调用 9.0K · 压缩线 12.0K') + ).toBeInTheDocument() + }) + it('restores persisted context usage and compression state after restart', async () => { const settings = await api.settings.getRuntime() const profile = settings.modelProfiles[0]! @@ -2689,8 +2774,15 @@ describe('App', () => { defaultModelProfileId: profile.id, modelProfiles: settings.modelProfiles.map((candidate) => ({ ...candidate, - contextWindowTokens: 32_000 - })) + contextWindowTokens: undefined + })), + contextCompression: { + enabled: true, + triggerTokens: 12_000, + recentRawTokens: 4_000, + modelSource: { kind: 'current' }, + summaryPrompt: 'Preserve important facts.' + } }) vi.mocked(api.conversations.list).mockResolvedValueOnce([ { @@ -2703,9 +2795,6 @@ describe('App', () => { contextMetrics: { runtimeSelectionKey: `model:${profile.id}`, contextTokens: 9_000, - effectiveTriggerTokens: 20_000, - contextWindowTokens: 32_000, - compressionEnabled: true, source: 'estimated', basis: 'conversation' }, @@ -2740,7 +2829,7 @@ describe('App', () => { expect( await screen.findByText( - '压缩后对话估算 ≈9.0K / 32.0K · 28%' + '压缩后对话估算 ≈9.0K · 压缩线 12.0K' ) ).toBeInTheDocument() expect( @@ -4649,6 +4738,17 @@ describe('App', () => { coveredThroughMessageId: messages[0]!.id, summary: '用户提出了第一轮问题。' } + const settings = await api.settings.getRuntime() + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + contextCompression: { + enabled: true, + triggerTokens: 20_000, + recentRawTokens: 4_000, + modelSource: { kind: 'current' }, + summaryPrompt: 'Preserve important facts.' + } + }) vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: conversationId, @@ -4723,6 +4823,9 @@ describe('App', () => { expect( await screen.findByText('已压缩 Continue 对话历史') ).toBeInTheDocument() + expect( + await screen.findByText(/压缩后对话估算/u) + ).not.toHaveTextContent('压缩线') await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith([ expect.objectContaining({ @@ -5358,8 +5461,39 @@ describe('App', () => { }) it('migrates legacy startup conversations with replace when SQLite has no local conversation', async () => { + const settings = await api.settings.getRuntime() + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + modelProfiles: settings.modelProfiles.map((profile) => ({ + ...profile, + contextWindowTokens: undefined + })), + contextCompression: { + enabled: true, + triggerTokens: 12_000, + recentRawTokens: 4_000, + modelSource: { kind: 'current' }, + summaryPrompt: 'Preserve important facts.' + } + }) + const normalizedContextMetrics = { + runtimeSelectionKey: `model:${settings.defaultModelProfileId}`, + contextTokens: 9_000, + source: 'provider' as const, + basis: 'model-call' as const + } const legacyConversation = { id: '00000000-0000-4000-8000-000000000461', + runtimeSelection: { + provider: 'model' as const, + profileId: settings.defaultModelProfileId + }, + contextMetrics: { + ...normalizedContextMetrics, + effectiveTriggerTokens: 20_000, + contextWindowTokens: 32_000, + compressionEnabled: true + }, title: '待迁移旧会话', updatedAt: 1_775_000_000_000, messages: [ @@ -5381,10 +5515,14 @@ describe('App', () => { expect( await screen.findByText('旧版浏览器存储消息') ).toBeInTheDocument() + expect( + screen.getByText('本次调用 9.0K · 压缩线 12.0K') + ).toBeInTheDocument() await waitFor(() => expect(api.conversations.replace).toHaveBeenCalledWith([ expect.objectContaining({ ...legacyConversation, + contextMetrics: normalizedContextMetrics, projectId }) ]) @@ -5443,11 +5581,35 @@ describe('App', () => { }) it('preserves a legacy Auto conversation without silently persisting a replacement', async () => { + const settings = await api.settings.getRuntime() + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + provider: 'auto', + opencodeBaseUrl: '', + opencodeEmbedded: false, + modelProfiles: settings.modelProfiles.map((profile) => ({ + ...profile, + contextWindowTokens: 32_000 + })), + contextCompression: { + enabled: true, + triggerTokens: 20_000, + recentRawTokens: 4_000, + modelSource: { kind: 'current' }, + summaryPrompt: 'Preserve important facts.' + } + }) vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: '00000000-0000-4000-8000-000000000020', projectId, runtimeSelection: { provider: 'auto' }, + contextMetrics: { + runtimeSelectionKey: 'auto:default', + contextTokens: 9_000, + source: 'provider', + basis: 'model-call' + }, title: '旧自动对话', updatedAt: 1, messages: [ @@ -5466,8 +5628,49 @@ describe('App', () => { expect( await screen.findByRole('button', { name: /自动.*sonnet-5/u }) ).toBeInTheDocument() + expect( + screen.queryByText(/本次调用 9\.0K/u) + ).not.toBeInTheDocument() + expect(screen.queryByText(/压缩线/u)).not.toBeInTheDocument() expect(api.conversations.replace).not.toHaveBeenCalled() expect(api.conversations.saveLocal).not.toHaveBeenCalled() + + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '刷新自动 Runtime 用量' } + }) + fireEvent.click(screen.getByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + const request = run.mock.calls[0]?.[0] + if (!request) { + throw new Error('Missing request') + } + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'context-metrics', + contextTokens: 9_000, + effectiveTriggerTokens: 20_000, + compressionEnabled: false, + source: 'provider' + }) + }) + expect(screen.getByText(/本次调用 9\.0K/u)).toBeInTheDocument() + expect(screen.queryByText(/压缩线/u)).not.toBeInTheDocument() + const currentRuntimeSelectionKey = + settings.opencodeModelSource.kind === 'profile' + ? `opencode:${settings.opencodeModelSource.profileId}` + : 'opencode:platform' + await waitFor(() => + expect(api.conversations.saveLocal).toHaveBeenCalledWith([ + expect.objectContaining({ + header: expect.objectContaining({ + contextMetrics: expect.objectContaining({ + runtimeSelectionKey: currentRuntimeSelectionKey + }) + }) + }) + ]) + ) }) it('keeps a removed model selection visible until the user replaces it', async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f061717..ed7962b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -76,7 +76,8 @@ import { import { buildConversationSummaryHistory, estimatedContextRequestOverheadTokens, - estimateMessagesTokens + estimateMessagesTokens, + getEffectiveContextTriggerTokens } from '../../shared/context-window' import { agentRuntimeSelectionKey, @@ -114,6 +115,7 @@ import type { import { conversationAttachmentSchema, conversationContextCompressionMarkerSchema, + conversationContextMetricsSchema, conversationMessageBlocksSchema, interactiveWorkModes, normalizeInteractiveWorkMode, @@ -963,6 +965,11 @@ function isConversationAttachment( return conversationAttachmentSchema.safeParse(value).success } +function parseConversationContextMetrics(value: unknown) { + const parsed = conversationContextMetricsSchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} + function loadConversations( greeting: string, interruptedStatus: string @@ -984,6 +991,12 @@ function loadConversations( .slice(0, 100) .map((conversation) => ({ ...conversation, + contextMetrics: + conversation.contextMetrics === undefined + ? undefined + : parseConversationContextMetrics( + conversation.contextMetrics + ), messages: conversation.messages.slice(-500).map((message) => message.state === 'streaming' ? { @@ -1212,6 +1225,19 @@ function getProjectDefaultRuntimeSelection( : selection } +function resolveContextMetricsRuntimeSelection( + selection: AgentRuntimeSelection, + settings: RuntimeSettings +): AgentRuntimeSelection { + if (selection.provider !== 'auto') { + return selection + } + return getRuntimeSelectionForProvider( + settings.provider === 'auto' ? 'opencode' : settings.provider, + settings + ) +} + function getRuntimeSelectionLabel( selection: AgentRuntimeSelection | undefined, settings: RuntimeSettings | undefined, @@ -3405,16 +3431,14 @@ function App(): React.JSX.Element { } }) } else if (event.type === 'context-metrics') { - const { requestId: _requestId, type: _type, ...metrics } = event - void _requestId - void _type setConversations((current) => current.map((conversation) => conversation.id === run.conversationId ? { ...conversation, contextMetrics: { - ...metrics, + contextTokens: event.contextTokens, + source: event.source, basis: 'model-call', runtimeSelectionKey: run.runtimeSelectionKey } @@ -3475,11 +3499,6 @@ function App(): React.JSX.Element { contextMetrics: { runtimeSelectionKey: run.runtimeSelectionKey, contextTokens: estimatedAfterTokens, - effectiveTriggerTokens: - event.effectiveTriggerTokens, - contextWindowTokens: - event.contextWindowTokens, - compressionEnabled: true, source: 'estimated', basis: 'conversation' }, @@ -5434,7 +5453,12 @@ function App(): React.JSX.Element { messageId: assistantMessage.id, projectId: projectIdSnapshot, runtimeSelectionKey: agentRuntimeSelectionKey( - runtimeSelectionSnapshot + runtimeSettings + ? resolveContextMetricsRuntimeSelection( + runtimeSelectionSnapshot, + runtimeSettings + ) + : runtimeSelectionSnapshot ) }) preparingConversations.current.delete(conversationId) @@ -5592,27 +5616,6 @@ function App(): React.JSX.Element { content: message.content })) ]) - const selectedProfileId = - 'profileId' in activeRuntimeSelection - ? activeRuntimeSelection.profileId - : undefined - const configuredSelection = runtimeSettings - ? getRuntimeSelectionForProvider( - activeRuntimeSelection.provider, - runtimeSettings - ) - : undefined - const configuredProfileId = - configuredSelection && - 'profileId' in configuredSelection - ? configuredSelection.profileId - : undefined - const contextWindowTokens = - runtimeSettings?.modelProfiles.find( - (profile) => - profile.id === - (selectedProfileId ?? configuredProfileId) - )?.contextWindowTokens setConversations((current) => current.map((conversation) => conversation.id === activeConversation.id @@ -5623,15 +5626,6 @@ function App(): React.JSX.Element { runtimeSelectionKey: activeRuntimeSelectionKey, contextTokens: estimatedAfterTokens, - effectiveTriggerTokens: - contextWindowTokens ?? - runtimeSettings?.contextCompression - ?.triggerTokens ?? - defaultContextCompressionSettings.triggerTokens, - ...(contextWindowTokens - ? { contextWindowTokens } - : {}), - compressionEnabled: false, source: 'estimated', basis: 'conversation' }, @@ -6175,28 +6169,42 @@ function App(): React.JSX.Element { ) { return undefined } - if ( - activeRuntimeSelection.provider === 'model' && - runtimeSettings.modelProfiles.find( - (candidate) => - candidate.id === activeRuntimeSelection.profileId - )?.protocol === 'openai-images-generations' - ) { + const resolvedRuntimeSelection = + resolveContextMetricsRuntimeSelection( + activeRuntimeSelection, + runtimeSettings + ) + const activeModelProfile = + 'profileId' in resolvedRuntimeSelection && + resolvedRuntimeSelection.profileId + ? runtimeSettings.modelProfiles.find( + (candidate) => + candidate.id === resolvedRuntimeSelection.profileId + ) + : undefined + if (activeModelProfile?.protocol === 'openai-images-generations') { return undefined } const latest = activeConversation.contextMetrics const applicableLatest = - latest?.runtimeSelectionKey === activeRuntimeSelectionKey + latest?.runtimeSelectionKey === + agentRuntimeSelectionKey(resolvedRuntimeSelection) ? latest : undefined if (!applicableLatest) { return undefined } + const compressionSettings = + runtimeSettings.contextCompression ?? + defaultContextCompressionSettings const contextTokens = applicableLatest.contextTokens - const effectiveTriggerTokens = - applicableLatest.effectiveTriggerTokens - const denominatorTokens = - applicableLatest.contextWindowTokens + const contextWindowTokens = + activeModelProfile?.contextWindowTokens + const effectiveTriggerTokens = getEffectiveContextTriggerTokens({ + triggerTokens: compressionSettings.triggerTokens, + contextWindowTokens + }) + const denominatorTokens = contextWindowTokens const percentage = denominatorTokens === undefined ? undefined @@ -6207,8 +6215,10 @@ function App(): React.JSX.Element { return { contextTokens, effectiveTriggerTokens, - contextWindowTokens: applicableLatest.contextWindowTokens, - compressionEnabled: applicableLatest.compressionEnabled, + contextWindowTokens, + compressionEnabled: + resolvedRuntimeSelection.provider === 'model' && + compressionSettings.enabled, source: applicableLatest.source, basis: applicableLatest.basis ?? @@ -6222,7 +6232,6 @@ function App(): React.JSX.Element { }, [ activeConversation, activeRuntimeSelection, - activeRuntimeSelectionKey, runtimeSettings ]) diff --git a/src/renderer/src/RuntimeCustomizationSection.tsx b/src/renderer/src/RuntimeCustomizationSection.tsx index 5322f59..de303f9 100644 --- a/src/renderer/src/RuntimeCustomizationSection.tsx +++ b/src/renderer/src/RuntimeCustomizationSection.tsx @@ -1,12 +1,20 @@ import { + forwardRef, memo, useCallback, useEffect, + useImperativeHandle, useMemo, useRef, useState } from 'react' -import { Boxes, RefreshCw, Plus, Trash2 } from 'lucide-react' +import { + Boxes, + Plus, + RefreshCw, + RotateCcw, + Trash2 +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { runtimeCustomizationLimits, @@ -15,13 +23,16 @@ import { type RuntimeCustomizationSettings, type RuntimeNativeSnapshot } from '../../shared/contracts' -import type { AppNotificationInput } from './notifications' import { EmptyState, PageTabs } from './WorkspacePrimitives' type RuntimeCustomizationSectionProps = { provider: CustomizableRuntimeProvider profileId?: string - onNotify?: (notification: AppNotificationInput) => void + onDirtyChange?: (dirty: boolean) => void +} + +export type RuntimeCustomizationSectionHandle = { + save: () => Promise } type RuntimeCustomizationError = { @@ -143,6 +154,25 @@ const NativeInventoryTabs = memo(function NativeInventoryTabs({ ) }) +const NativeInventoryStatus = memo(function NativeInventoryStatus({ + snapshot +}: { + snapshot: RuntimeNativeSnapshot +}): React.JSX.Element { + return ( +
+ {snapshot.detail} +
+ ) +}) + const NativeInventory = memo(function NativeInventory({ snapshot }: { @@ -323,24 +353,6 @@ const NativeInventory = memo(function NativeInventory({ ] return (
-
- - {t( - `runtime.customization.inventory.status.${snapshot.inventoryStatus}` - )} - - {snapshot.detail} -
-

- {t('runtime.customization.inventory.nativeOnly')} -

(function RuntimeCustomizationSection( + { provider, profileId, onDirtyChange }, + ref +): React.JSX.Element { const { t } = useTranslation('settings') const loadGeneration = useRef(0) const [settings, setSettings] = useState() + const [persistedSettings, setPersistedSettings] = + useState() const [snapshot, setSnapshot] = useState() const [selectedPresetId, setSelectedPresetId] = useState('') const [loading, setLoading] = useState(true) @@ -365,11 +381,32 @@ export function RuntimeCustomizationSection({ const [saving, setSaving] = useState(false) const [error, setError] = useState() const [mergedRulesOpen, setMergedRulesOpen] = useState(false) + const settingsDirty = useMemo( + () => + Boolean( + settings && + persistedSettings && + JSON.stringify(settings) !== JSON.stringify(persistedSettings) + ), + [persistedSettings, settings] + ) + const settingsRef = useRef(settings) + const settingsDirtyRef = useRef(settingsDirty) + + useEffect(() => { + settingsRef.current = settings + }, [settings]) + + useEffect(() => { + settingsDirtyRef.current = settingsDirty + onDirtyChange?.(settingsDirty) + }, [onDirtyChange, settingsDirty]) const load = useCallback(async (): Promise => { const generation = ++loadGeneration.current setLoading(true) setRefreshing(false) + setSnapshot(undefined) setError(undefined) try { const [nextSettings, nextSnapshot] = await Promise.all([ @@ -382,15 +419,21 @@ export function RuntimeCustomizationSection({ if (generation !== loadGeneration.current) { return } - setSettings(nextSettings) + if (!settingsDirtyRef.current) { + setSettings(nextSettings) + setPersistedSettings(nextSettings) + } setSnapshot(nextSnapshot) + const draftSettings = settingsDirtyRef.current + ? settingsRef.current + : nextSettings setSelectedPresetId((current) => - nextSettings.continue.presets.some( + draftSettings?.continue.presets.some( (preset) => preset.id === current ) ? current - : nextSettings.continue.defaultPresetId ?? - nextSettings.continue.presets[0]?.id ?? + : draftSettings?.continue.defaultPresetId ?? + draftSettings?.continue.presets[0]?.id ?? '' ) } catch (reason) { @@ -460,9 +503,12 @@ export function RuntimeCustomizationSection({ [selectedPresetId, settings] ) - const save = async (): Promise => { + const save = useCallback(async (): Promise => { if (!settings) { - return + return true + } + if (!settingsDirty) { + return true } setSaving(true) setError(undefined) @@ -472,11 +518,8 @@ export function RuntimeCustomizationSection({ settings ) setSettings(saved) - onNotify?.({ - tone: 'success', - message: t('runtime.customization.saved'), - dedupeKey: 'runtime-customization-saved' - }) + setPersistedSettings(saved) + return true } catch (reason) { setError({ message: errorMessage( @@ -485,9 +528,25 @@ export function RuntimeCustomizationSection({ ), retry: 'save' }) + return false } finally { setSaving(false) } + }, [settings, settingsDirty, t]) + + useImperativeHandle(ref, () => ({ save }), [save]) + + const discardChanges = (): void => { + if (!persistedSettings) { + return + } + setSettings(persistedSettings) + setSelectedPresetId( + persistedSettings.continue.defaultPresetId ?? + persistedSettings.continue.presets[0]?.id ?? + '' + ) + setError(undefined) } const addPreset = (): void => { @@ -561,7 +620,7 @@ export function RuntimeCustomizationSection({
) : null} @@ -1094,4 +1171,4 @@ export function RuntimeCustomizationSection({ {snapshot ? : null} ) -} +}) diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 0809b8c..3250076 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -7,10 +7,11 @@ import { waitFor, within } from '@testing-library/react' -import { useState } from 'react' +import { useRef, useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AssistantExpert } from '../../shared/assistant-contracts' import type { + CustomizableRuntimeProvider, DesktopApi, RuntimeSettings } from '../../shared/contracts' @@ -24,7 +25,10 @@ import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts' import { builtinMcpServers } from '../../shared/builtin-mcp-servers' import { builtinModelToolGroups } from '../../shared/builtin-model-tools' import { SettingsPanel } from './SettingsPanel' -import { RuntimeCustomizationSection } from './RuntimeCustomizationSection' +import { + RuntimeCustomizationSection, + type RuntimeCustomizationSectionHandle +} from './RuntimeCustomizationSection' import { changeUiLocale } from './i18n' import { UiLocaleProvider } from './i18n/UiLocaleProvider' @@ -481,6 +485,29 @@ const getRuntimeNativeSnapshot = vi.fn< } })) +function RuntimeCustomizationTestHarness({ + provider +}: { + provider: CustomizableRuntimeProvider +}): React.JSX.Element { + const customizationRef = + useRef(null) + return ( + <> + + + + ) +} + describe('SettingsPanel runtime files', () => { beforeEach(async () => { vi.clearAllMocks() @@ -1986,16 +2013,30 @@ describe('SettingsPanel runtime files', () => { async (input) => input ) - render() + render() - const agent = await screen.findByLabelText(/默认 Runtime Agent/u) + const agent = await screen.findByLabelText('默认 Agent') expect(agent).toHaveValue('planner') + const nativeStatus = screen.getByRole('status') + expect(nativeStatus).toHaveTextContent('OpenCode 原生能力已就绪') + expect( + Boolean( + nativeStatus.compareDocumentPosition(agent) & + Node.DOCUMENT_POSITION_FOLLOWING + ) + ).toBe(true) + expect(screen.getByText('能力与默认配置')).toBeInTheDocument() + expect(screen.queryByText('Runtime 原生能力')).not.toBeInTheDocument() + expect(screen.queryByText('OpenCode 默认 Agent')).not.toBeInTheDocument() + expect( + screen.queryByText('Runtime 原生能力可用') + ).not.toBeInTheDocument() const inventoryTabs = screen.getByRole('tablist', { - name: 'Runtime 原生能力' + name: '能力清单' }) expect(within(inventoryTabs).getAllByRole('tab')).toHaveLength(11) const agentsTab = within(inventoryTabs).getByRole('tab', { - name: /原生 Agents/u + name: /^Agents/u }) const agentsPanel = screen.getByRole('tabpanel') expect(agentsTab).toHaveAttribute('aria-selected', 'true') @@ -2008,7 +2049,7 @@ describe('SettingsPanel runtime files', () => { expect(screen.getByText('Explorer')).toBeInTheDocument() fireEvent.keyDown(agentsTab, { key: 'ArrowRight' }) const toolsTab = within(inventoryTabs).getByRole('tab', { - name: /原生 Tools/u + name: /^Tools/u }) expect(toolsTab).toHaveAttribute('aria-selected', 'true') expect(toolsTab).toHaveFocus() @@ -2026,20 +2067,20 @@ describe('SettingsPanel runtime files', () => { expect(screen.getByText('未发现')).toBeInTheDocument() expect( screen.getByText( - '当前 Runtime 未报告此类别中的可用原生能力。' + '当前 Runtime 未报告此类别中的可用能力。' ) ).toBeInTheDocument() expect(screen.queryByText('Native MCP')).not.toBeInTheDocument() fireEvent.click( within(inventoryTabs).getByRole('tab', { - name: /原生 MCP/u + name: /^MCP/u }) ) expect(screen.getByText('Native MCP')).toBeInTheDocument() expect(screen.queryByText('Explorer')).not.toBeInTheDocument() fireEvent.click( within(inventoryTabs).getByRole('tab', { - name: /原生 Skills/u + name: /^Skills/u }) ) expect(screen.getByText('Native Skill')).toBeInTheDocument() @@ -2047,6 +2088,9 @@ describe('SettingsPanel runtime files', () => { expect(screen.queryByText('GoodBuddy MCP')).not.toBeInTheDocument() fireEvent.change(agent, { target: { value: 'reviewer' } }) + expect( + screen.getByText(/有未保存的 Runtime 定制更改/u) + ).toBeInTheDocument() fireEvent.click( screen.getByRole('button', { name: '保存 Runtime 定制' }) ) @@ -2056,6 +2100,11 @@ describe('SettingsPanel runtime files', () => { continue: { presets: [] } }) ) + await waitFor(() => + expect( + screen.queryByText(/有未保存的 Runtime 定制更改/u) + ).not.toBeInTheDocument() + ) }) it('distinguishes external OpenCode connectivity from readable native inventory', async () => { @@ -2073,14 +2122,28 @@ describe('SettingsPanel runtime files', () => { render() - expect( - await screen.findByText('仅确认 Runtime 连接') - ).toBeInTheDocument() - expect(screen.getByRole('status')).toHaveTextContent( + expect(await screen.findByRole('status')).toHaveTextContent( 'External OpenCode connection only' ) expect( - screen.queryByText('Runtime 原生能力可用') + screen.queryByText('仅确认 Runtime 连接') + ).not.toBeInTheDocument() + }) + + it('uses a guided empty state without a second save action', async () => { + render() + + expect( + await screen.findByText('还没有 Continue 预设') + ).toBeInTheDocument() + expect( + screen.getByText('使用上方“添加预设”创建 Rules 与 Prompt 模板。') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: '添加预设' }) + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: '保存 Runtime 定制' }) ).not.toBeInTheDocument() }) @@ -2150,7 +2213,7 @@ describe('SettingsPanel runtime files', () => { async (input) => input ) - render() + render() expect( await screen.findByLabelText('默认配置预设') @@ -2159,25 +2222,25 @@ describe('SettingsPanel runtime files', () => { screen.getByText('查看最终合并的 2 条 Rule') ).toBeInTheDocument() const inventoryTabs = screen.getByRole('tablist', { - name: 'Runtime 原生能力' + name: '能力清单' }) fireEvent.click( within(inventoryTabs).getByRole('tab', { - name: /原生 Tools/u + name: /^Tools/u }) ) expect( - screen.getByText('当前 Runtime 不支持静态发现原生 Tools') + screen.getByText('当前 Runtime 不支持静态发现 Tools') ).toBeInTheDocument() fireEvent.click( within(inventoryTabs).getByRole('tab', { - name: /原生 Skills/u + name: /^Skills/u }) ) expect(screen.getByText('未发现')).toBeInTheDocument() fireEvent.click( within(inventoryTabs).getByRole('tab', { - name: /MCP Resources/u + name: /^Resources/u }) ) expect( @@ -2240,7 +2303,7 @@ describe('SettingsPanel runtime files', () => { .mockRejectedValueOnce(new Error('保存失败')) .mockImplementationOnce(async (input) => input) - render() + render() const nameInput = await screen.findByLabelText('预设名称') fireEvent.change(nameInput, { @@ -2248,7 +2311,7 @@ describe('SettingsPanel runtime files', () => { }) fireEvent.click( screen.getByRole('button', { - name: '刷新 Runtime 原生能力' + name: '刷新能力清单' }) ) await waitFor(() => @@ -2284,6 +2347,82 @@ describe('SettingsPanel runtime files', () => { expect(getRuntimeCustomizationSettings).toHaveBeenCalledOnce() }) + it('saves Runtime customization with the page action and protects unsaved drafts', async () => { + const presetId = '00000000-0000-4000-8000-000000000705' + const customization = { + opencode: {}, + continue: { + presets: [ + { + id: presetId, + name: 'Saved preset', + rules: [], + prompts: [] + } + ] + } + } + getRuntimeCustomizationSettings + .mockResolvedValueOnce(customization) + .mockResolvedValueOnce(customization) + updateRuntimeCustomizationSettings.mockImplementationOnce( + async (input) => input + ) + const onClose = vi.fn() + + render( + {})} + onClose={onClose} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(await screen.findByRole('button', { name: 'Continue' })) + const presetName = await screen.findByLabelText('预设名称') + fireEvent.change(presetName, { + target: { value: 'Unsaved preset' } + }) + expect( + screen.getByText(/有未保存的 Runtime 定制更改/u) + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole('tab', { name: '外观' })) + expect(presetName).toHaveValue('Unsaved preset') + fireEvent.click(screen.getByRole('button', { name: '关闭设置' })) + + expect(onClose).not.toHaveBeenCalled() + expect( + await screen.findByText( + '请先保存或撤销 Runtime 定制更改,再关闭设置中心。' + ) + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '保存设置' })) + await waitFor(() => + expect(updateRuntimeCustomizationSettings).toHaveBeenCalledWith( + expect.objectContaining({ + continue: expect.objectContaining({ + presets: [ + expect.objectContaining({ name: 'Unsaved preset' }) + ] + }) + }) + ) + ) + expect(updateRuntime).toHaveBeenCalled() + await waitFor(() => + expect( + screen.queryByText(/有未保存的 Runtime 定制更改/u) + ).not.toBeInTheDocument() + ) + + fireEvent.click(screen.getByRole('button', { name: '关闭设置' })) + expect(onClose).toHaveBeenCalledOnce() + }) + it('opens only saved Runtime-owned config files or fixed config directories', async () => { getRuntime.mockResolvedValueOnce({ ...runtimeSettings, diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx index 62062db..98cc85f 100644 --- a/src/renderer/src/SettingsPanel.tsx +++ b/src/renderer/src/SettingsPanel.tsx @@ -46,7 +46,10 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection' import { EmbeddingSettingsSection } from './EmbeddingSettingsSection' import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection' import { DshMarketplaceSection } from './DshMarketplaceSection' -import { RuntimeCustomizationSection } from './RuntimeCustomizationSection' +import { + RuntimeCustomizationSection, + type RuntimeCustomizationSectionHandle +} from './RuntimeCustomizationSection' import { PageHeader, SegmentedControl } from './WorkspacePrimitives' import { SettingsCategoryHeader, @@ -658,6 +661,23 @@ export function SettingsPanel({ useState(false) const [agentRuntimeType, setAgentRuntimeType] = useState('opencode') + const [runtimeCustomizationDirty, setRuntimeCustomizationDirty] = + useState(false) + const runtimeCustomizationRef = + useRef(null) + const handleRuntimeCustomizationDirtyChange = useCallback( + (dirty: boolean): void => { + setRuntimeCustomizationDirty(dirty) + if (!dirty) { + setError((current) => + current === t('runtime.customization.unsavedClose') + ? undefined + : current + ) + } + }, + [t] + ) const settingsBodyRef = useRef(null) const hydrateSettings = useCallback( ( @@ -848,6 +868,11 @@ export function SettingsPanel({ } const close = (): void => { + if (runtimeCustomizationDirty) { + setActiveTab('runtime') + setError(t('runtime.customization.unsavedClose')) + return + } setModelProfiles((profiles) => profiles.map((profile) => ({ ...profile, @@ -1008,6 +1033,13 @@ export function SettingsPanel({ } } onSaved(value) + if (activeTab === 'runtime') { + const customizationSaved = + (await runtimeCustomizationRef.current?.save()) ?? true + if (!customizationSaved) { + return undefined + } + } if (notifySuccess) { onNotify({ tone: 'success', @@ -1889,18 +1921,6 @@ export function SettingsPanel({ )} - {agentRuntimeType === 'opencode' && ( - - )} - {agentRuntimeType === 'continue' && (
@@ -2108,17 +2128,6 @@ export function SettingsPanel({
)} - {agentRuntimeType === 'continue' && ( - - )} {agentRuntimeType === 'deepseek-harness' && (
@@ -2216,23 +2225,25 @@ export function SettingsPanel({
- )} - {agentRuntimeType === 'deepseek-harness' && ( - - )} - {agentRuntimeType === 'deepseek-harness' && ( - )} )} + + {activeTab === 'runtime' && + agentRuntimeType === 'deepseek-harness' && ( + + )} {activeTab === 'model' && ( <> diff --git a/src/renderer/src/WorkspacePrimitives.test.tsx b/src/renderer/src/WorkspacePrimitives.test.tsx index 4f108ea..8ea26e1 100644 --- a/src/renderer/src/WorkspacePrimitives.test.tsx +++ b/src/renderer/src/WorkspacePrimitives.test.tsx @@ -174,6 +174,45 @@ describe('WorkspacePrimitives', () => { ) }) + it('keeps full-page settings navigation readable and content fluid', () => { + expect(stylesheet).toMatch( + /\.settings-page \.settings-panel__body\s*\{[^}]*grid-template-columns:\s*190px minmax\(0,\s*1fr\);/u + ) + expect(stylesheet).toMatch( + /\.settings-page \.settings-panel__content\s*\{[^}]*width:\s*min\(100%,\s*var\(--content-standard\)\);/u + ) + expect(stylesheet).toMatch( + /\.settings-page \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u + ) + expect(stylesheet).toMatch( + /\.settings-page \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u + ) + }) + + it('keeps Runtime customization hierarchy and dangerous actions clear', () => { + expect(stylesheet).toMatch( + /\.runtime-customization-section__header strong\s*\{[^}]*font-size:\s*var\(--font-section-title\);/u + ) + expect(stylesheet).toMatch( + /\.runtime-customization-editor\s*\{[^}]*padding:\s*0;[^}]*border:\s*0;[^}]*background:\s*transparent;/u + ) + expect(stylesheet).toMatch( + /\.runtime-native-inventory__status\s*\{[^}]*display:\s*flex;[^}]*min-height:\s*42px;/u + ) + expect(stylesheet).not.toContain( + '.runtime-native-inventory__header' + ) + expect(stylesheet).toMatch( + /\.runtime-customization-section__dirty\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*space-between;/u + ) + expect(stylesheet).toMatch( + /\.danger-ghost\s*\{[^}]*color:\s*var\(--danger\);[^}]*font-size:\s*var\(--font-caption\);/u + ) + expect(stylesheet).toMatch( + /\.danger-ghost:disabled\s*\{[^}]*color:\s*var\(--text-muted\);[^}]*cursor:\s*not-allowed;/u + ) + }) + it('keeps knowledge settings cards separated as page sections', () => { expect(stylesheet).toMatch( /\.knowledge-settings\s*\{[^}]*display:\s*grid;[^}]*width:\s*min\(920px,\s*100%\);[^}]*gap:\s*var\(--space-6\);/u diff --git a/src/renderer/src/i18n/locales/en-US/settings.ts b/src/renderer/src/i18n/locales/en-US/settings.ts index fb15377..ae91918 100644 --- a/src/renderer/src/i18n/locales/en-US/settings.ts +++ b/src/renderer/src/i18n/locales/en-US/settings.ts @@ -196,48 +196,41 @@ export const settings = { permissions: 'Choose Ask or Execute in a conversation. Ask can use only read-only capabilities allowed by the current Runtime. Execute can use enabled tools, and records tool calls in Activity.', customization: { - title: 'Native Runtime customization', + title: 'Capabilities and defaults', description: - 'Manage capabilities supplied by this Runtime. The inventory excludes Skills assigned by GoodBuddy and temporary GoodBuddy MCP servers.', - refresh: 'Refresh native Runtime capabilities', + 'Configure this Runtime’s defaults and inspect its built-in capabilities. The inventory excludes Skills assigned by GoodBuddy and temporary MCP servers.', + refresh: 'Refresh capability inventory', retry: 'Retry', - loading: 'Loading native Runtime capabilities…', - save: 'Save Runtime customization', - saving: 'Saving…', - saved: 'Runtime customization saved', + loading: 'Loading capability inventory…', + unsaved: + 'There are unsaved Runtime customization changes. Use “Save settings” at the top right to save everything together.', + discard: 'Discard customization changes', + unsavedClose: + 'Save or discard the Runtime customization changes before closing Settings.', enabled: 'Enabled', disabled: 'Disabled', errors: { - load: 'Could not load native Runtime capabilities', + load: 'Could not load capability inventory', save: 'Could not save Runtime customization' }, inventory: { - tabsAriaLabel: 'Native Runtime capabilities', - nativeOnly: - 'Only Runtime-native configuration and plugin capabilities are shown. GoodBuddy assignments are excluded.', - status: { - available: 'Native Runtime capabilities available', - partial: 'Native Runtime capabilities partially available', - unavailable: 'Native Runtime capabilities unavailable', - 'connection-only': 'Runtime connection only', - unsupported: 'Native inventory is not supported' - }, - agents: 'Native Agents', - tools: 'Native Tools', - skills: 'Native Skills', - mcp: 'Native MCP', + tabsAriaLabel: 'Capability inventory', + agents: 'Agents', + tools: 'Tools', + skills: 'Skills', + mcp: 'MCP', commands: 'Commands', - rules: 'Native Rules', - prompts: 'Prompt templates', - resources: 'MCP Resources', - lsp: 'LSP status', - formatters: 'Formatter status', + rules: 'Rules', + prompts: 'Prompts', + resources: 'Resources', + lsp: 'LSP', + formatters: 'Formatters', empty: 'None detected', emptyDescription: - 'The current Runtime did not report any native capabilities in this category.', + 'The current Runtime did not report any capabilities in this category.', unsupported: 'Not supported by this Runtime', toolsUnsupported: - 'This Runtime does not support static discovery of native Tools', + 'This Runtime does not support static discovery of Tools', toolModes: 'Ask: {{ask}} · Execute: {{execute}}', toolKind: { read: 'Read', @@ -286,12 +279,13 @@ export const settings = { title: 'Context and compaction' }, opencode: { - defaultAgent: 'Default Runtime Agent', + defaultAgent: 'Default Agent', runtimeDefault: 'Let OpenCode choose', agentDescription: 'Applies only to GoodBuddy-managed local OpenCode. A conversation can still select a different Agent.' }, continue: { + editorTitle: 'Continue configuration presets', editPreset: 'Edit configuration preset', noPresets: 'No presets', addPreset: 'Add preset', @@ -321,8 +315,9 @@ export const settings = { promptContent: '{{name}} content', removePrompt: 'Delete Prompt {{name}}', mergedRules: 'View {{count}} merged Rules', + emptyPresetTitle: 'No Continue presets yet', emptyPreset: - 'Add a preset to manage Continue Rules and Prompt templates.' + 'Use “Add preset” above to create Rules and Prompt templates.' } }, advanced: 'Advanced settings', diff --git a/src/renderer/src/i18n/locales/zh-CN/settings.ts b/src/renderer/src/i18n/locales/zh-CN/settings.ts index 836eb44..6cc0114 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settings.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settings.ts @@ -175,46 +175,39 @@ export const settings = { permissions: '对话时可选择 Ask 或 Execute。Ask 仅可调用当前 Runtime 允许的只读能力;Execute 可调用已启用工具,调用过程会记录到活动。', customization: { - title: 'Runtime 原生定制', + title: '能力与默认配置', description: - '管理当前 Runtime 自己提供的能力;清单不包含 GoodBuddy 分配的 Skills 或临时 MCP。', - refresh: '刷新 Runtime 原生能力', + '设置当前 Runtime 的默认项并查看自带能力;清单不包含 GoodBuddy 分配的 Skills 或临时 MCP。', + refresh: '刷新能力清单', retry: '重试', - loading: '正在读取 Runtime 原生能力…', - save: '保存 Runtime 定制', - saving: '正在保存…', - saved: '已保存 Runtime 定制设置', + loading: '正在读取能力清单…', + unsaved: + '有未保存的 Runtime 定制更改,点击页面右上角“保存设置”统一保存。', + discard: '撤销定制更改', + unsavedClose: + '请先保存或撤销 Runtime 定制更改,再关闭设置中心。', enabled: '已启用', disabled: '已停用', errors: { - load: '读取 Runtime 原生能力失败', + load: '读取能力清单失败', save: '保存 Runtime 定制失败' }, inventory: { - tabsAriaLabel: 'Runtime 原生能力', - nativeOnly: - '这里只显示 Runtime 原生配置与插件能力,不显示 GoodBuddy 分配内容。', - status: { - available: 'Runtime 原生能力可用', - partial: 'Runtime 原生能力部分可用', - unavailable: 'Runtime 原生能力不可用', - 'connection-only': '仅确认 Runtime 连接', - unsupported: 'Runtime 不支持原生能力清单' - }, - agents: '原生 Agents', - tools: '原生 Tools', - skills: '原生 Skills', - mcp: '原生 MCP', + tabsAriaLabel: '能力清单', + agents: 'Agents', + tools: 'Tools', + skills: 'Skills', + mcp: 'MCP', commands: 'Commands', - rules: '原生 Rules', - prompts: 'Prompt 模板', - resources: 'MCP Resources', - lsp: 'LSP 状态', - formatters: 'Formatter 状态', + rules: 'Rules', + prompts: 'Prompts', + resources: 'Resources', + lsp: 'LSP', + formatters: 'Formatters', empty: '未发现', - emptyDescription: '当前 Runtime 未报告此类别中的可用原生能力。', + emptyDescription: '当前 Runtime 未报告此类别中的可用能力。', unsupported: '当前 Runtime 不支持', - toolsUnsupported: '当前 Runtime 不支持静态发现原生 Tools', + toolsUnsupported: '当前 Runtime 不支持静态发现 Tools', toolModes: 'Ask:{{ask}} · Execute:{{execute}}', toolKind: { read: '读取', @@ -263,12 +256,13 @@ export const settings = { title: '上下文与压缩' }, opencode: { - defaultAgent: '默认 Runtime Agent', + defaultAgent: '默认 Agent', runtimeDefault: '由 OpenCode 选择', agentDescription: '只影响 GoodBuddy 管理的本机 OpenCode;聊天中仍可为当前对话单独选择。' }, continue: { + editorTitle: 'Continue 配置预设', editPreset: '编辑配置预设', noPresets: '尚无预设', addPreset: '添加预设', @@ -295,7 +289,8 @@ export const settings = { promptContent: '{{name}} 内容', removePrompt: '删除 Prompt {{name}}', mergedRules: '查看最终合并的 {{count}} 条 Rule', - emptyPreset: '添加一个预设后即可管理 Rules 与 Prompt 模板。' + emptyPresetTitle: '还没有 Continue 预设', + emptyPreset: '使用上方“添加预设”创建 Rules 与 Prompt 模板。' } }, advanced: '高级设置', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 4c1556f..bfedf6a 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -4949,7 +4949,7 @@ button > svg { padding: 24px 32px 32px; overflow: hidden; align-items: stretch; - grid-template-columns: 190px minmax(0, 760px); + grid-template-columns: 190px minmax(0, 1fr); justify-content: start; gap: 28px; } @@ -4986,6 +4986,7 @@ button > svg { } .settings-page .settings-panel__content { + width: min(100%, var(--content-standard)); min-height: 0; padding-right: var(--space-1); overflow-y: auto; @@ -5048,9 +5049,16 @@ button > svg { text-align: left; } +.settings-page .settings-tabs button strong { + font-size: var(--font-body); + line-height: 1.35; +} + .settings-page .settings-tabs button small { display: block; margin-top: 3px; + font-size: var(--font-caption); + line-height: 1.35; } .settings-page .settings-tabs button[aria-selected='true'] { @@ -5306,14 +5314,25 @@ button > svg { .runtime-customization-section__header > div { min-width: 0; -} - -.runtime-customization-section__header > div { + display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-1); } +.runtime-customization-section__header strong { + color: var(--text-primary); + font-size: var(--font-section-title); + font-weight: 650; + line-height: 1.4; +} + +.runtime-customization-section__header small { + color: var(--text-muted); + font-size: var(--font-caption); + line-height: 1.5; +} + .runtime-customization-section__error { display: flex; align-items: center; @@ -5332,18 +5351,22 @@ button > svg { .runtime-customization-editor { min-width: 0; margin: 0; - padding: var(--space-4); - border: 1px solid var(--border-default); - border-radius: var(--radius-card); - background: var(--surface-subtle); + padding: 0; + border: 0; + background: transparent; } .runtime-native-inventory__status { - display: grid; + display: flex; + align-items: center; + min-height: 42px; padding: var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-control); - gap: var(--space-1); + color: var(--text-secondary); + font-size: var(--font-body); + line-height: 1.5; + overflow-wrap: anywhere; } .runtime-native-inventory__status--available { @@ -5363,13 +5386,16 @@ button > svg { background: var(--warning-subtle); } -.runtime-native-inventory__status small { +.runtime-customization-section__dirty { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: var(--space-3); + border-top: 1px solid var(--border-subtle); color: var(--text-secondary); - overflow-wrap: anywhere; -} - -.runtime-customization-section__actions { - justify-content: flex-end; + flex-wrap: wrap; + font-size: var(--font-caption); + gap: var(--space-3); } .runtime-native-inventory > .page-tabs { @@ -7419,6 +7445,39 @@ details.settings-section > :not(summary) + :not(summary) { color: var(--text-secondary); } +.danger-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--control-height); + padding: 0 var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--danger); + cursor: pointer; + font-size: var(--font-caption); + font-weight: 650; + gap: var(--space-2); +} + +.danger-ghost:hover { + border-color: var(--danger-border); + background: var(--danger-subtle); +} + +.danger-ghost:disabled { + border-color: transparent; + background: transparent; + color: var(--text-muted); + cursor: not-allowed; + opacity: 0.65; +} + +.danger-ghost.icon-button { + padding: 0; +} + .conversation-row { position: relative; display: flex; diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 985d7ab..650edca 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -251,22 +251,31 @@ export const conversationContextMetricsSchema = z .object({ runtimeSelectionKey: z.string().trim().min(1).max(1_000), contextTokens: z.number().int().nonnegative().max(50_000_000), + source: z.enum(['provider', 'estimated']), + basis: z.enum(['model-call', 'conversation']).optional(), + // Accepted only to migrate snapshots saved before display settings + // were derived from the current Runtime configuration. effectiveTriggerTokens: z .number() .int() .nonnegative() - .max(10_000_000), + .max(10_000_000) + .optional(), contextWindowTokens: z .number() .int() .nonnegative() .max(10_000_000) .optional(), - compressionEnabled: z.boolean(), - source: z.enum(['provider', 'estimated']), - basis: z.enum(['model-call', 'conversation']).optional() + compressionEnabled: z.boolean().optional(), }) .strict() + .transform((metrics) => ({ + runtimeSelectionKey: metrics.runtimeSelectionKey, + contextTokens: metrics.contextTokens, + source: metrics.source, + basis: metrics.basis + })) export type ConversationContextMetrics = z.infer< typeof conversationContextMetricsSchema