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