fix: streamline settings and context status

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 自动部署。
This commit is contained in:
mesalogo
2026-08-16 19:31:49 +08:00
parent b56b0f8826
commit 80526c57bf
18 changed files with 953 additions and 306 deletions
+47
View File
@@ -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
+1 -1
View File
@@ -27,7 +27,7 @@
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 - [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] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
- [x] **OpenCode Runtime 定制**GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 ResourcesTools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用原生 Compact;外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 - [x] **OpenCode Runtime 定制**GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 ResourcesTools 单独显示读取、文件修改、命令、网络、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 原生会话管理继续暂缓。 - [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 原生会话管理继续暂缓。
+4 -1
View File
@@ -523,13 +523,16 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
### 13.7 设置中心 ### 13.7 设置中心
- 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。 - 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。
- 左侧分类标题使用正文级字号,分类说明使用辅助字号;右侧内容区在可用空间内流式伸缩,最大宽度使用 `standard` 壳层的 `960px`,不得以页面专属较窄宽度压缩表单。
- 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。 - 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。 - 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。 - 所有分类使用共享的 `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,添加与删除使用明确按钮和可访问名称。 - OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。
- MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。 - MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes", "description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy", "desktopName": "GoodBuddy",
"homepage": "https://github.com/mesalogo/goodbuddy", "homepage": "https://mesalogo.github.io/goodbuddy/",
"author": { "author": {
"name": "MesaLogo" "name": "MesaLogo"
}, },
+11
View File
@@ -2,6 +2,17 @@
`sites` 是无需构建步骤或额外依赖的静态官网源码,可直接托管整个目录。 `sites` 是无需构建步骤或额外依赖的静态官网源码,可直接托管整个目录。
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
## 部署
`.github/workflows/pages.yml` 会在 `main` 分支中的官网文件发生变化后,
校验并部署整个 `sites` 目录。工作流也支持在 GitHub Actions 中手动运行。
首次部署前,需要在 GitHub 仓库的 **Settings > Pages** 中将 **Source**
设为 **GitHub Actions**。站点使用项目 Pages 地址,不需要 `CNAME` 文件
或自定义域名 DNS 配置。
## 本地预览 ## 本地预览
在仓库根目录运行: 在仓库根目录运行:
+1
View File
@@ -7,6 +7,7 @@
name="description" name="description"
content="GoodBuddy 是桌面 AI 助手,支持项目知识库、魔法笔记、远程消息通道和受控工具执行。" content="GoodBuddy 是桌面 AI 助手,支持项目知识库、魔法笔记、远程消息通道和受控工具执行。"
/> />
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/" />
<meta name="theme-color" content="#f6f8fb" /> <meta name="theme-color" content="#f6f8fb" />
<title>GoodBuddy|桌面 AI 助手</title> <title>GoodBuddy|桌面 AI 助手</title>
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="./assets/favicon.svg" type="image/svg+xml" />
+6
View File
@@ -56,6 +56,12 @@ for (const [relativePath, content] of [
report(/<html\s+lang="zh-CN">/.test(html), "页面语言必须是 zh-CN"); report(/<html\s+lang="zh-CN">/.test(html), "页面语言必须是 zh-CN");
report(/<meta\s+name="viewport"/.test(html), "缺少 viewport 元信息"); report(/<meta\s+name="viewport"/.test(html), "缺少 viewport 元信息");
report(
/<link\s+rel="canonical"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/"\s*\/>/.test(
html,
),
"canonical 地址必须指向 GitHub Pages 正式站点",
);
report((html.match(/<h1[\s>]/g) ?? []).length === 1, "页面必须且只能包含一个 h1"); report((html.match(/<h1[\s>]/g) ?? []).length === 1, "页面必须且只能包含一个 h1");
report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接"); report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接");
report(/<main\s+id="main-content">/.test(html), "缺少 main-content 主区域"); report(/<main\s+id="main-content">/.test(html), "缺少 main-content 主区域");
+46 -3
View File
@@ -1466,9 +1466,6 @@ describe('AssistantDatabase', () => {
contextMetrics: { contextMetrics: {
runtimeSelectionKey: `model:${channelDefaultProfileId}`, runtimeSelectionKey: `model:${channelDefaultProfileId}`,
contextTokens: 9_000, contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
source: 'estimated' as const, source: 'estimated' as const,
basis: 'conversation' as const basis: 'conversation' as const
}, },
@@ -1574,6 +1571,26 @@ describe('AssistantDatabase', () => {
) )
const durable = new DatabaseSync(databasePath) 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( expect(
durable durable
.prepare( .prepare(
@@ -1595,6 +1612,32 @@ describe('AssistantDatabase', () => {
request_id: null 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() durable.close()
database.close() database.close()
}) })
+213 -10
View File
@@ -2607,7 +2607,7 @@ describe('App', () => {
})), })),
contextCompression: { contextCompression: {
enabled: true, enabled: true,
triggerTokens: 20_000, triggerTokens: 12_000,
recentRawTokens: 4_000, recentRawTokens: 4_000,
modelSource: { kind: 'current' }, modelSource: { kind: 'current' },
summaryPrompt: 'Preserve important facts.' summaryPrompt: 'Preserve important facts.'
@@ -2636,7 +2636,7 @@ describe('App', () => {
}) })
expect( expect(
screen.getByText('本次调用 22.0K · 压缩线 20.0K') screen.getByText('本次调用 22.0K · 压缩线 12.0K')
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.queryByRole('progressbar', { screen.queryByRole('progressbar', {
@@ -2661,14 +2661,99 @@ describe('App', () => {
expect( expect(
screen.getByText( screen.getByText(
'压缩后对话估算 ≈9.4K · 压缩线 20.0K' '压缩后对话估算 ≈9.4K · 压缩线 12.0K'
) )
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.queryByText('本次调用 22.0K · 压缩线 20.0K') screen.queryByText('本次调用 22.0K · 压缩线 12.0K')
).not.toBeInTheDocument() ).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(<App />)
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 () => { it('restores persisted context usage and compression state after restart', async () => {
const settings = await api.settings.getRuntime() const settings = await api.settings.getRuntime()
const profile = settings.modelProfiles[0]! const profile = settings.modelProfiles[0]!
@@ -2689,8 +2774,15 @@ describe('App', () => {
defaultModelProfileId: profile.id, defaultModelProfileId: profile.id,
modelProfiles: settings.modelProfiles.map((candidate) => ({ modelProfiles: settings.modelProfiles.map((candidate) => ({
...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([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
@@ -2703,9 +2795,6 @@ describe('App', () => {
contextMetrics: { contextMetrics: {
runtimeSelectionKey: `model:${profile.id}`, runtimeSelectionKey: `model:${profile.id}`,
contextTokens: 9_000, contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
source: 'estimated', source: 'estimated',
basis: 'conversation' basis: 'conversation'
}, },
@@ -2740,7 +2829,7 @@ describe('App', () => {
expect( expect(
await screen.findByText( await screen.findByText(
'压缩后对话估算 ≈9.0K / 32.0K · 28%' '压缩后对话估算 ≈9.0K · 压缩线 12.0K'
) )
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
@@ -4649,6 +4738,17 @@ describe('App', () => {
coveredThroughMessageId: messages[0]!.id, coveredThroughMessageId: messages[0]!.id,
summary: '用户提出了第一轮问题。' 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([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
id: conversationId, id: conversationId,
@@ -4723,6 +4823,9 @@ describe('App', () => {
expect( expect(
await screen.findByText('已压缩 Continue 对话历史') await screen.findByText('已压缩 Continue 对话历史')
).toBeInTheDocument() ).toBeInTheDocument()
expect(
await screen.findByText(//u)
).not.toHaveTextContent('压缩线')
await waitFor(() => await waitFor(() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([ expect(api.conversations.saveLocal).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
@@ -5358,8 +5461,39 @@ describe('App', () => {
}) })
it('migrates legacy startup conversations with replace when SQLite has no local conversation', async () => { 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 = { const legacyConversation = {
id: '00000000-0000-4000-8000-000000000461', 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: '待迁移旧会话', title: '待迁移旧会话',
updatedAt: 1_775_000_000_000, updatedAt: 1_775_000_000_000,
messages: [ messages: [
@@ -5381,10 +5515,14 @@ describe('App', () => {
expect( expect(
await screen.findByText('旧版浏览器存储消息') await screen.findByText('旧版浏览器存储消息')
).toBeInTheDocument() ).toBeInTheDocument()
expect(
screen.getByText('本次调用 9.0K · 压缩线 12.0K')
).toBeInTheDocument()
await waitFor(() => await waitFor(() =>
expect(api.conversations.replace).toHaveBeenCalledWith([ expect(api.conversations.replace).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
...legacyConversation, ...legacyConversation,
contextMetrics: normalizedContextMetrics,
projectId projectId
}) })
]) ])
@@ -5443,11 +5581,35 @@ describe('App', () => {
}) })
it('preserves a legacy Auto conversation without silently persisting a replacement', async () => { 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([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
id: '00000000-0000-4000-8000-000000000020', id: '00000000-0000-4000-8000-000000000020',
projectId, projectId,
runtimeSelection: { provider: 'auto' }, runtimeSelection: { provider: 'auto' },
contextMetrics: {
runtimeSelectionKey: 'auto:default',
contextTokens: 9_000,
source: 'provider',
basis: 'model-call'
},
title: '旧自动对话', title: '旧自动对话',
updatedAt: 1, updatedAt: 1,
messages: [ messages: [
@@ -5466,8 +5628,49 @@ describe('App', () => {
expect( expect(
await screen.findByRole('button', { name: /.*sonnet-5/u }) await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument() ).toBeInTheDocument()
expect(
screen.queryByText(/ 9\.0K/u)
).not.toBeInTheDocument()
expect(screen.queryByText(/线/u)).not.toBeInTheDocument()
expect(api.conversations.replace).not.toHaveBeenCalled() expect(api.conversations.replace).not.toHaveBeenCalled()
expect(api.conversations.saveLocal).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 () => { it('keeps a removed model selection visible until the user replaces it', async () => {
+64 -55
View File
@@ -76,7 +76,8 @@ import {
import { import {
buildConversationSummaryHistory, buildConversationSummaryHistory,
estimatedContextRequestOverheadTokens, estimatedContextRequestOverheadTokens,
estimateMessagesTokens estimateMessagesTokens,
getEffectiveContextTriggerTokens
} from '../../shared/context-window' } from '../../shared/context-window'
import { import {
agentRuntimeSelectionKey, agentRuntimeSelectionKey,
@@ -114,6 +115,7 @@ import type {
import { import {
conversationAttachmentSchema, conversationAttachmentSchema,
conversationContextCompressionMarkerSchema, conversationContextCompressionMarkerSchema,
conversationContextMetricsSchema,
conversationMessageBlocksSchema, conversationMessageBlocksSchema,
interactiveWorkModes, interactiveWorkModes,
normalizeInteractiveWorkMode, normalizeInteractiveWorkMode,
@@ -963,6 +965,11 @@ function isConversationAttachment(
return conversationAttachmentSchema.safeParse(value).success return conversationAttachmentSchema.safeParse(value).success
} }
function parseConversationContextMetrics(value: unknown) {
const parsed = conversationContextMetricsSchema.safeParse(value)
return parsed.success ? parsed.data : undefined
}
function loadConversations( function loadConversations(
greeting: string, greeting: string,
interruptedStatus: string interruptedStatus: string
@@ -984,6 +991,12 @@ function loadConversations(
.slice(0, 100) .slice(0, 100)
.map((conversation) => ({ .map((conversation) => ({
...conversation, ...conversation,
contextMetrics:
conversation.contextMetrics === undefined
? undefined
: parseConversationContextMetrics(
conversation.contextMetrics
),
messages: conversation.messages.slice(-500).map((message) => messages: conversation.messages.slice(-500).map((message) =>
message.state === 'streaming' message.state === 'streaming'
? { ? {
@@ -1212,6 +1225,19 @@ function getProjectDefaultRuntimeSelection(
: selection : 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( function getRuntimeSelectionLabel(
selection: AgentRuntimeSelection | undefined, selection: AgentRuntimeSelection | undefined,
settings: RuntimeSettings | undefined, settings: RuntimeSettings | undefined,
@@ -3405,16 +3431,14 @@ function App(): React.JSX.Element {
} }
}) })
} else if (event.type === 'context-metrics') { } else if (event.type === 'context-metrics') {
const { requestId: _requestId, type: _type, ...metrics } = event
void _requestId
void _type
setConversations((current) => setConversations((current) =>
current.map((conversation) => current.map((conversation) =>
conversation.id === run.conversationId conversation.id === run.conversationId
? { ? {
...conversation, ...conversation,
contextMetrics: { contextMetrics: {
...metrics, contextTokens: event.contextTokens,
source: event.source,
basis: 'model-call', basis: 'model-call',
runtimeSelectionKey: run.runtimeSelectionKey runtimeSelectionKey: run.runtimeSelectionKey
} }
@@ -3475,11 +3499,6 @@ function App(): React.JSX.Element {
contextMetrics: { contextMetrics: {
runtimeSelectionKey: run.runtimeSelectionKey, runtimeSelectionKey: run.runtimeSelectionKey,
contextTokens: estimatedAfterTokens, contextTokens: estimatedAfterTokens,
effectiveTriggerTokens:
event.effectiveTriggerTokens,
contextWindowTokens:
event.contextWindowTokens,
compressionEnabled: true,
source: 'estimated', source: 'estimated',
basis: 'conversation' basis: 'conversation'
}, },
@@ -5434,7 +5453,12 @@ function App(): React.JSX.Element {
messageId: assistantMessage.id, messageId: assistantMessage.id,
projectId: projectIdSnapshot, projectId: projectIdSnapshot,
runtimeSelectionKey: agentRuntimeSelectionKey( runtimeSelectionKey: agentRuntimeSelectionKey(
runtimeSelectionSnapshot runtimeSettings
? resolveContextMetricsRuntimeSelection(
runtimeSelectionSnapshot,
runtimeSettings
)
: runtimeSelectionSnapshot
) )
}) })
preparingConversations.current.delete(conversationId) preparingConversations.current.delete(conversationId)
@@ -5592,27 +5616,6 @@ function App(): React.JSX.Element {
content: message.content 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) => setConversations((current) =>
current.map((conversation) => current.map((conversation) =>
conversation.id === activeConversation.id conversation.id === activeConversation.id
@@ -5623,15 +5626,6 @@ function App(): React.JSX.Element {
runtimeSelectionKey: runtimeSelectionKey:
activeRuntimeSelectionKey, activeRuntimeSelectionKey,
contextTokens: estimatedAfterTokens, contextTokens: estimatedAfterTokens,
effectiveTriggerTokens:
contextWindowTokens ??
runtimeSettings?.contextCompression
?.triggerTokens ??
defaultContextCompressionSettings.triggerTokens,
...(contextWindowTokens
? { contextWindowTokens }
: {}),
compressionEnabled: false,
source: 'estimated', source: 'estimated',
basis: 'conversation' basis: 'conversation'
}, },
@@ -6175,28 +6169,42 @@ function App(): React.JSX.Element {
) { ) {
return undefined return undefined
} }
if ( const resolvedRuntimeSelection =
activeRuntimeSelection.provider === 'model' && resolveContextMetricsRuntimeSelection(
runtimeSettings.modelProfiles.find( activeRuntimeSelection,
runtimeSettings
)
const activeModelProfile =
'profileId' in resolvedRuntimeSelection &&
resolvedRuntimeSelection.profileId
? runtimeSettings.modelProfiles.find(
(candidate) => (candidate) =>
candidate.id === activeRuntimeSelection.profileId candidate.id === resolvedRuntimeSelection.profileId
)?.protocol === 'openai-images-generations' )
) { : undefined
if (activeModelProfile?.protocol === 'openai-images-generations') {
return undefined return undefined
} }
const latest = activeConversation.contextMetrics const latest = activeConversation.contextMetrics
const applicableLatest = const applicableLatest =
latest?.runtimeSelectionKey === activeRuntimeSelectionKey latest?.runtimeSelectionKey ===
agentRuntimeSelectionKey(resolvedRuntimeSelection)
? latest ? latest
: undefined : undefined
if (!applicableLatest) { if (!applicableLatest) {
return undefined return undefined
} }
const compressionSettings =
runtimeSettings.contextCompression ??
defaultContextCompressionSettings
const contextTokens = applicableLatest.contextTokens const contextTokens = applicableLatest.contextTokens
const effectiveTriggerTokens = const contextWindowTokens =
applicableLatest.effectiveTriggerTokens activeModelProfile?.contextWindowTokens
const denominatorTokens = const effectiveTriggerTokens = getEffectiveContextTriggerTokens({
applicableLatest.contextWindowTokens triggerTokens: compressionSettings.triggerTokens,
contextWindowTokens
})
const denominatorTokens = contextWindowTokens
const percentage = const percentage =
denominatorTokens === undefined denominatorTokens === undefined
? undefined ? undefined
@@ -6207,8 +6215,10 @@ function App(): React.JSX.Element {
return { return {
contextTokens, contextTokens,
effectiveTriggerTokens, effectiveTriggerTokens,
contextWindowTokens: applicableLatest.contextWindowTokens, contextWindowTokens,
compressionEnabled: applicableLatest.compressionEnabled, compressionEnabled:
resolvedRuntimeSelection.provider === 'model' &&
compressionSettings.enabled,
source: applicableLatest.source, source: applicableLatest.source,
basis: basis:
applicableLatest.basis ?? applicableLatest.basis ??
@@ -6222,7 +6232,6 @@ function App(): React.JSX.Element {
}, [ }, [
activeConversation, activeConversation,
activeRuntimeSelection, activeRuntimeSelection,
activeRuntimeSelectionKey,
runtimeSettings runtimeSettings
]) ])
+132 -55
View File
@@ -1,12 +1,20 @@
import { import {
forwardRef,
memo, memo,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle,
useMemo, useMemo,
useRef, useRef,
useState useState
} from 'react' } 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 { useTranslation } from 'react-i18next'
import { import {
runtimeCustomizationLimits, runtimeCustomizationLimits,
@@ -15,13 +23,16 @@ import {
type RuntimeCustomizationSettings, type RuntimeCustomizationSettings,
type RuntimeNativeSnapshot type RuntimeNativeSnapshot
} from '../../shared/contracts' } from '../../shared/contracts'
import type { AppNotificationInput } from './notifications'
import { EmptyState, PageTabs } from './WorkspacePrimitives' import { EmptyState, PageTabs } from './WorkspacePrimitives'
type RuntimeCustomizationSectionProps = { type RuntimeCustomizationSectionProps = {
provider: CustomizableRuntimeProvider provider: CustomizableRuntimeProvider
profileId?: string profileId?: string
onNotify?: (notification: AppNotificationInput) => void onDirtyChange?: (dirty: boolean) => void
}
export type RuntimeCustomizationSectionHandle = {
save: () => Promise<boolean>
} }
type RuntimeCustomizationError = { type RuntimeCustomizationError = {
@@ -143,6 +154,25 @@ const NativeInventoryTabs = memo(function NativeInventoryTabs({
) )
}) })
const NativeInventoryStatus = memo(function NativeInventoryStatus({
snapshot
}: {
snapshot: RuntimeNativeSnapshot
}): React.JSX.Element {
return (
<div
className={`runtime-native-inventory__status runtime-native-inventory__status--${snapshot.inventoryStatus}`}
role={
snapshot.inventoryStatus === 'unavailable'
? 'alert'
: 'status'
}
>
<span>{snapshot.detail}</span>
</div>
)
})
const NativeInventory = memo(function NativeInventory({ const NativeInventory = memo(function NativeInventory({
snapshot snapshot
}: { }: {
@@ -323,24 +353,6 @@ const NativeInventory = memo(function NativeInventory({
] ]
return ( return (
<div className="runtime-native-inventory"> <div className="runtime-native-inventory">
<div
className={`runtime-native-inventory__status runtime-native-inventory__status--${snapshot.inventoryStatus}`}
role={
snapshot.inventoryStatus === 'unavailable'
? 'alert'
: 'status'
}
>
<strong>
{t(
`runtime.customization.inventory.status.${snapshot.inventoryStatus}`
)}
</strong>
<small>{snapshot.detail}</small>
</div>
<p className="settings-section__description">
{t('runtime.customization.inventory.nativeOnly')}
</p>
<NativeInventoryTabs <NativeInventoryTabs
groups={groups} groups={groups}
provider={snapshot.provider} provider={snapshot.provider}
@@ -349,15 +361,19 @@ const NativeInventory = memo(function NativeInventory({
) )
}) })
export function RuntimeCustomizationSection({ export const RuntimeCustomizationSection = forwardRef<
provider, RuntimeCustomizationSectionHandle,
profileId, RuntimeCustomizationSectionProps
onNotify >(function RuntimeCustomizationSection(
}: RuntimeCustomizationSectionProps): React.JSX.Element { { provider, profileId, onDirtyChange },
ref
): React.JSX.Element {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const loadGeneration = useRef(0) const loadGeneration = useRef(0)
const [settings, setSettings] = const [settings, setSettings] =
useState<RuntimeCustomizationSettings>() useState<RuntimeCustomizationSettings>()
const [persistedSettings, setPersistedSettings] =
useState<RuntimeCustomizationSettings>()
const [snapshot, setSnapshot] = useState<RuntimeNativeSnapshot>() const [snapshot, setSnapshot] = useState<RuntimeNativeSnapshot>()
const [selectedPresetId, setSelectedPresetId] = useState('') const [selectedPresetId, setSelectedPresetId] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -365,11 +381,32 @@ export function RuntimeCustomizationSection({
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<RuntimeCustomizationError>() const [error, setError] = useState<RuntimeCustomizationError>()
const [mergedRulesOpen, setMergedRulesOpen] = useState(false) 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<void> => { const load = useCallback(async (): Promise<void> => {
const generation = ++loadGeneration.current const generation = ++loadGeneration.current
setLoading(true) setLoading(true)
setRefreshing(false) setRefreshing(false)
setSnapshot(undefined)
setError(undefined) setError(undefined)
try { try {
const [nextSettings, nextSnapshot] = await Promise.all([ const [nextSettings, nextSnapshot] = await Promise.all([
@@ -382,15 +419,21 @@ export function RuntimeCustomizationSection({
if (generation !== loadGeneration.current) { if (generation !== loadGeneration.current) {
return return
} }
if (!settingsDirtyRef.current) {
setSettings(nextSettings) setSettings(nextSettings)
setPersistedSettings(nextSettings)
}
setSnapshot(nextSnapshot) setSnapshot(nextSnapshot)
const draftSettings = settingsDirtyRef.current
? settingsRef.current
: nextSettings
setSelectedPresetId((current) => setSelectedPresetId((current) =>
nextSettings.continue.presets.some( draftSettings?.continue.presets.some(
(preset) => preset.id === current (preset) => preset.id === current
) )
? current ? current
: nextSettings.continue.defaultPresetId ?? : draftSettings?.continue.defaultPresetId ??
nextSettings.continue.presets[0]?.id ?? draftSettings?.continue.presets[0]?.id ??
'' ''
) )
} catch (reason) { } catch (reason) {
@@ -460,9 +503,12 @@ export function RuntimeCustomizationSection({
[selectedPresetId, settings] [selectedPresetId, settings]
) )
const save = async (): Promise<void> => { const save = useCallback(async (): Promise<boolean> => {
if (!settings) { if (!settings) {
return return true
}
if (!settingsDirty) {
return true
} }
setSaving(true) setSaving(true)
setError(undefined) setError(undefined)
@@ -472,11 +518,8 @@ export function RuntimeCustomizationSection({
settings settings
) )
setSettings(saved) setSettings(saved)
onNotify?.({ setPersistedSettings(saved)
tone: 'success', return true
message: t('runtime.customization.saved'),
dedupeKey: 'runtime-customization-saved'
})
} catch (reason) { } catch (reason) {
setError({ setError({
message: errorMessage( message: errorMessage(
@@ -485,9 +528,25 @@ export function RuntimeCustomizationSection({
), ),
retry: 'save' retry: 'save'
}) })
return false
} finally { } finally {
setSaving(false) 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 => { const addPreset = (): void => {
@@ -561,7 +620,7 @@ export function RuntimeCustomizationSection({
<button <button
aria-label={t('runtime.customization.refresh')} aria-label={t('runtime.customization.refresh')}
className="icon-button" className="icon-button"
disabled={loading || refreshing || saving} disabled={!snapshot || refreshing || saving}
onClick={() => void refreshSnapshot()} onClick={() => void refreshSnapshot()}
title={t('runtime.customization.refresh')} title={t('runtime.customization.refresh')}
type="button" type="button"
@@ -600,15 +659,21 @@ export function RuntimeCustomizationSection({
</p> </p>
) : null} ) : null}
{snapshot ? (
<NativeInventoryStatus snapshot={snapshot} />
) : null}
{provider === 'opencode' && settings && snapshot ? ( {provider === 'opencode' && settings && snapshot ? (
<fieldset <label
aria-busy={saving} aria-busy={saving}
className="runtime-customization-editor" className="field runtime-customization-editor"
disabled={saving}
> >
<label className="field">
<span>{t('runtime.customization.opencode.defaultAgent')}</span> <span>{t('runtime.customization.opencode.defaultAgent')}</span>
<select <select
aria-label={t(
'runtime.customization.opencode.defaultAgent'
)}
disabled={saving}
onChange={(event) => onChange={(event) =>
setSettings({ setSettings({
...settings, ...settings,
@@ -639,15 +704,17 @@ export function RuntimeCustomizationSection({
{t('runtime.customization.opencode.agentDescription')} {t('runtime.customization.opencode.agentDescription')}
</small> </small>
</label> </label>
</fieldset>
) : null} ) : null}
{provider === 'continue' && settings ? ( {provider === 'continue' && settings && snapshot ? (
<fieldset <fieldset
aria-busy={saving} aria-busy={saving}
className="runtime-customization-editor" className="runtime-customization-editor"
disabled={saving} disabled={saving}
> >
<legend className="sr-only">
{t('runtime.customization.continue.editorTitle')}
</legend>
<div className="runtime-preset-toolbar"> <div className="runtime-preset-toolbar">
<label className="field"> <label className="field">
<span> <span>
@@ -1069,24 +1136,34 @@ export function RuntimeCustomizationSection({
</details> </details>
</div> </div>
) : ( ) : (
<p className="settings-empty-state"> <EmptyState
{t('runtime.customization.continue.emptyPreset')} description={t(
</p> 'runtime.customization.continue.emptyPreset'
)}
icon={<Boxes size={22} />}
level="table"
title={t(
'runtime.customization.continue.emptyPresetTitle'
)}
/>
)} )}
</fieldset> </fieldset>
) : null} ) : null}
{settings && provider !== 'deepseek-harness' ? ( {settingsDirty && provider !== 'deepseek-harness' ? (
<div className="settings-actions runtime-customization-section__actions"> <div
className="runtime-customization-section__dirty"
role="status"
>
<span>{t('runtime.customization.unsaved')}</span>
<button <button
className="primary-button" className="secondary-button"
disabled={refreshing || saving} disabled={saving}
onClick={() => void save()} onClick={discardChanges}
type="button" type="button"
> >
{saving <RotateCcw aria-hidden="true" size={14} />
? t('runtime.customization.saving') {t('runtime.customization.discard')}
: t('runtime.customization.save')}
</button> </button>
</div> </div>
) : null} ) : null}
@@ -1094,4 +1171,4 @@ export function RuntimeCustomizationSection({
{snapshot ? <NativeInventory snapshot={snapshot} /> : null} {snapshot ? <NativeInventory snapshot={snapshot} /> : null}
</section> </section>
) )
} })
+162 -23
View File
@@ -7,10 +7,11 @@ import {
waitFor, waitFor,
within within
} from '@testing-library/react' } from '@testing-library/react'
import { useState } from 'react' import { useRef, useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantExpert } from '../../shared/assistant-contracts' import type { AssistantExpert } from '../../shared/assistant-contracts'
import type { import type {
CustomizableRuntimeProvider,
DesktopApi, DesktopApi,
RuntimeSettings RuntimeSettings
} from '../../shared/contracts' } from '../../shared/contracts'
@@ -24,7 +25,10 @@ import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers' import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools' import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import { SettingsPanel } from './SettingsPanel' import { SettingsPanel } from './SettingsPanel'
import { RuntimeCustomizationSection } from './RuntimeCustomizationSection' import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
} from './RuntimeCustomizationSection'
import { changeUiLocale } from './i18n' import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider' import { UiLocaleProvider } from './i18n/UiLocaleProvider'
@@ -481,6 +485,29 @@ const getRuntimeNativeSnapshot = vi.fn<
} }
})) }))
function RuntimeCustomizationTestHarness({
provider
}: {
provider: CustomizableRuntimeProvider
}): React.JSX.Element {
const customizationRef =
useRef<RuntimeCustomizationSectionHandle>(null)
return (
<>
<RuntimeCustomizationSection
provider={provider}
ref={customizationRef}
/>
<button
onClick={() => void customizationRef.current?.save()}
type="button"
>
Runtime
</button>
</>
)
}
describe('SettingsPanel runtime files', () => { describe('SettingsPanel runtime files', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -1986,16 +2013,30 @@ describe('SettingsPanel runtime files', () => {
async (input) => input async (input) => input
) )
render(<RuntimeCustomizationSection provider="opencode" />) render(<RuntimeCustomizationTestHarness provider="opencode" />)
const agent = await screen.findByLabelText(/ Runtime Agent/u) const agent = await screen.findByLabelText('默认 Agent')
expect(agent).toHaveValue('planner') 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', { const inventoryTabs = screen.getByRole('tablist', {
name: 'Runtime 原生能力' name: '能力清单'
}) })
expect(within(inventoryTabs).getAllByRole('tab')).toHaveLength(11) expect(within(inventoryTabs).getAllByRole('tab')).toHaveLength(11)
const agentsTab = within(inventoryTabs).getByRole('tab', { const agentsTab = within(inventoryTabs).getByRole('tab', {
name: / Agents/u name: /^Agents/u
}) })
const agentsPanel = screen.getByRole('tabpanel') const agentsPanel = screen.getByRole('tabpanel')
expect(agentsTab).toHaveAttribute('aria-selected', 'true') expect(agentsTab).toHaveAttribute('aria-selected', 'true')
@@ -2008,7 +2049,7 @@ describe('SettingsPanel runtime files', () => {
expect(screen.getByText('Explorer')).toBeInTheDocument() expect(screen.getByText('Explorer')).toBeInTheDocument()
fireEvent.keyDown(agentsTab, { key: 'ArrowRight' }) fireEvent.keyDown(agentsTab, { key: 'ArrowRight' })
const toolsTab = within(inventoryTabs).getByRole('tab', { const toolsTab = within(inventoryTabs).getByRole('tab', {
name: / Tools/u name: /^Tools/u
}) })
expect(toolsTab).toHaveAttribute('aria-selected', 'true') expect(toolsTab).toHaveAttribute('aria-selected', 'true')
expect(toolsTab).toHaveFocus() expect(toolsTab).toHaveFocus()
@@ -2026,20 +2067,20 @@ describe('SettingsPanel runtime files', () => {
expect(screen.getByText('未发现')).toBeInTheDocument() expect(screen.getByText('未发现')).toBeInTheDocument()
expect( expect(
screen.getByText( screen.getByText(
'当前 Runtime 未报告此类别中的可用原生能力。' '当前 Runtime 未报告此类别中的可用能力。'
) )
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.queryByText('Native MCP')).not.toBeInTheDocument() expect(screen.queryByText('Native MCP')).not.toBeInTheDocument()
fireEvent.click( fireEvent.click(
within(inventoryTabs).getByRole('tab', { within(inventoryTabs).getByRole('tab', {
name: / MCP/u name: /^MCP/u
}) })
) )
expect(screen.getByText('Native MCP')).toBeInTheDocument() expect(screen.getByText('Native MCP')).toBeInTheDocument()
expect(screen.queryByText('Explorer')).not.toBeInTheDocument() expect(screen.queryByText('Explorer')).not.toBeInTheDocument()
fireEvent.click( fireEvent.click(
within(inventoryTabs).getByRole('tab', { within(inventoryTabs).getByRole('tab', {
name: / Skills/u name: /^Skills/u
}) })
) )
expect(screen.getByText('Native Skill')).toBeInTheDocument() expect(screen.getByText('Native Skill')).toBeInTheDocument()
@@ -2047,6 +2088,9 @@ describe('SettingsPanel runtime files', () => {
expect(screen.queryByText('GoodBuddy MCP')).not.toBeInTheDocument() expect(screen.queryByText('GoodBuddy MCP')).not.toBeInTheDocument()
fireEvent.change(agent, { target: { value: 'reviewer' } }) fireEvent.change(agent, { target: { value: 'reviewer' } })
expect(
screen.getByText(/ Runtime /u)
).toBeInTheDocument()
fireEvent.click( fireEvent.click(
screen.getByRole('button', { name: '保存 Runtime 定制' }) screen.getByRole('button', { name: '保存 Runtime 定制' })
) )
@@ -2056,6 +2100,11 @@ describe('SettingsPanel runtime files', () => {
continue: { presets: [] } continue: { presets: [] }
}) })
) )
await waitFor(() =>
expect(
screen.queryByText(/ Runtime /u)
).not.toBeInTheDocument()
)
}) })
it('distinguishes external OpenCode connectivity from readable native inventory', async () => { it('distinguishes external OpenCode connectivity from readable native inventory', async () => {
@@ -2073,14 +2122,28 @@ describe('SettingsPanel runtime files', () => {
render(<RuntimeCustomizationSection provider="opencode" />) render(<RuntimeCustomizationSection provider="opencode" />)
expect( expect(await screen.findByRole('status')).toHaveTextContent(
await screen.findByText('仅确认 Runtime 连接')
).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent(
'External OpenCode connection only' 'External OpenCode connection only'
) )
expect( expect(
screen.queryByText('Runtime 原生能力可用') screen.queryByText('仅确认 Runtime 连接')
).not.toBeInTheDocument()
})
it('uses a guided empty state without a second save action', async () => {
render(<RuntimeCustomizationSection provider="continue" />)
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() ).not.toBeInTheDocument()
}) })
@@ -2150,7 +2213,7 @@ describe('SettingsPanel runtime files', () => {
async (input) => input async (input) => input
) )
render(<RuntimeCustomizationSection provider="continue" />) render(<RuntimeCustomizationTestHarness provider="continue" />)
expect( expect(
await screen.findByLabelText('默认配置预设') await screen.findByLabelText('默认配置预设')
@@ -2159,25 +2222,25 @@ describe('SettingsPanel runtime files', () => {
screen.getByText('查看最终合并的 2 条 Rule') screen.getByText('查看最终合并的 2 条 Rule')
).toBeInTheDocument() ).toBeInTheDocument()
const inventoryTabs = screen.getByRole('tablist', { const inventoryTabs = screen.getByRole('tablist', {
name: 'Runtime 原生能力' name: '能力清单'
}) })
fireEvent.click( fireEvent.click(
within(inventoryTabs).getByRole('tab', { within(inventoryTabs).getByRole('tab', {
name: / Tools/u name: /^Tools/u
}) })
) )
expect( expect(
screen.getByText('当前 Runtime 不支持静态发现原生 Tools') screen.getByText('当前 Runtime 不支持静态发现 Tools')
).toBeInTheDocument() ).toBeInTheDocument()
fireEvent.click( fireEvent.click(
within(inventoryTabs).getByRole('tab', { within(inventoryTabs).getByRole('tab', {
name: / Skills/u name: /^Skills/u
}) })
) )
expect(screen.getByText('未发现')).toBeInTheDocument() expect(screen.getByText('未发现')).toBeInTheDocument()
fireEvent.click( fireEvent.click(
within(inventoryTabs).getByRole('tab', { within(inventoryTabs).getByRole('tab', {
name: /MCP Resources/u name: /^Resources/u
}) })
) )
expect( expect(
@@ -2240,7 +2303,7 @@ describe('SettingsPanel runtime files', () => {
.mockRejectedValueOnce(new Error('保存失败')) .mockRejectedValueOnce(new Error('保存失败'))
.mockImplementationOnce(async (input) => input) .mockImplementationOnce(async (input) => input)
render(<RuntimeCustomizationSection provider="continue" />) render(<RuntimeCustomizationTestHarness provider="continue" />)
const nameInput = await screen.findByLabelText('预设名称') const nameInput = await screen.findByLabelText('预设名称')
fireEvent.change(nameInput, { fireEvent.change(nameInput, {
@@ -2248,7 +2311,7 @@ describe('SettingsPanel runtime files', () => {
}) })
fireEvent.click( fireEvent.click(
screen.getByRole('button', { screen.getByRole('button', {
name: '刷新 Runtime 原生能力' name: '刷新能力清单'
}) })
) )
await waitFor(() => await waitFor(() =>
@@ -2284,6 +2347,82 @@ describe('SettingsPanel runtime files', () => {
expect(getRuntimeCustomizationSettings).toHaveBeenCalledOnce() 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(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
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 () => { it('opens only saved Runtime-owned config files or fixed config directories', async () => {
getRuntime.mockResolvedValueOnce({ getRuntime.mockResolvedValueOnce({
...runtimeSettings, ...runtimeSettings,
+44 -33
View File
@@ -46,7 +46,10 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection' import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection' import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection' import { DshMarketplaceSection } from './DshMarketplaceSection'
import { RuntimeCustomizationSection } from './RuntimeCustomizationSection' import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
} from './RuntimeCustomizationSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives' import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import { import {
SettingsCategoryHeader, SettingsCategoryHeader,
@@ -658,6 +661,23 @@ export function SettingsPanel({
useState(false) useState(false)
const [agentRuntimeType, setAgentRuntimeType] = const [agentRuntimeType, setAgentRuntimeType] =
useState<AgentRuntimeType>('opencode') useState<AgentRuntimeType>('opencode')
const [runtimeCustomizationDirty, setRuntimeCustomizationDirty] =
useState(false)
const runtimeCustomizationRef =
useRef<RuntimeCustomizationSectionHandle>(null)
const handleRuntimeCustomizationDirtyChange = useCallback(
(dirty: boolean): void => {
setRuntimeCustomizationDirty(dirty)
if (!dirty) {
setError((current) =>
current === t('runtime.customization.unsavedClose')
? undefined
: current
)
}
},
[t]
)
const settingsBodyRef = useRef<HTMLDivElement>(null) const settingsBodyRef = useRef<HTMLDivElement>(null)
const hydrateSettings = useCallback( const hydrateSettings = useCallback(
( (
@@ -848,6 +868,11 @@ export function SettingsPanel({
} }
const close = (): void => { const close = (): void => {
if (runtimeCustomizationDirty) {
setActiveTab('runtime')
setError(t('runtime.customization.unsavedClose'))
return
}
setModelProfiles((profiles) => setModelProfiles((profiles) =>
profiles.map((profile) => ({ profiles.map((profile) => ({
...profile, ...profile,
@@ -1008,6 +1033,13 @@ export function SettingsPanel({
} }
} }
onSaved(value) onSaved(value)
if (activeTab === 'runtime') {
const customizationSaved =
(await runtimeCustomizationRef.current?.save()) ?? true
if (!customizationSaved) {
return undefined
}
}
if (notifySuccess) { if (notifySuccess) {
onNotify({ onNotify({
tone: 'success', tone: 'success',
@@ -1889,18 +1921,6 @@ export function SettingsPanel({
</details> </details>
</div> </div>
)} )}
{agentRuntimeType === 'opencode' && (
<RuntimeCustomizationSection
onNotify={onNotify}
profileId={
opencodeModelSource.kind === 'profile'
? opencodeModelSource.profileId
: undefined
}
provider="opencode"
/>
)}
{agentRuntimeType === 'continue' && ( {agentRuntimeType === 'continue' && (
<div className="settings-section"> <div className="settings-section">
<div className="settings-section__title"> <div className="settings-section__title">
@@ -2108,17 +2128,6 @@ export function SettingsPanel({
</details> </details>
</div> </div>
)} )}
{agentRuntimeType === 'continue' && (
<RuntimeCustomizationSection
onNotify={onNotify}
profileId={
continueModelSource.kind === 'profile'
? continueModelSource.profileId
: undefined
}
provider="continue"
/>
)}
{agentRuntimeType === 'deepseek-harness' && ( {agentRuntimeType === 'deepseek-harness' && (
<div className="settings-section"> <div className="settings-section">
<div className="settings-section__title"> <div className="settings-section__title">
@@ -2217,22 +2226,24 @@ export function SettingsPanel({
</details> </details>
</div> </div>
)} )}
{agentRuntimeType === 'deepseek-harness' && ( </>
)}
<div hidden={activeTab !== 'runtime'}>
<RuntimeCustomizationSection <RuntimeCustomizationSection
onNotify={onNotify} onDirtyChange={handleRuntimeCustomizationDirtyChange}
profileId={ profileId={
deepseekHarnessModelSource.kind === 'profile' activeRuntimeModelSource.kind === 'profile'
? deepseekHarnessModelSource.profileId ? activeRuntimeModelSource.profileId
: undefined : undefined
} }
provider="deepseek-harness" provider={agentRuntimeType}
ref={runtimeCustomizationRef}
/> />
)} </div>
{agentRuntimeType === 'deepseek-harness' && ( {activeTab === 'runtime' &&
agentRuntimeType === 'deepseek-harness' && (
<DshMarketplaceSection onNotify={onNotify} /> <DshMarketplaceSection onNotify={onNotify} />
)} )}
</>
)}
{activeTab === 'model' && ( {activeTab === 'model' && (
<> <>
@@ -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', () => { it('keeps knowledge settings cards separated as page sections', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.knowledge-settings\s*\{[^}]*display:\s*grid;[^}]*width:\s*min\(920px,\s*100%\);[^}]*gap:\s*var\(--space-6\);/u /\.knowledge-settings\s*\{[^}]*display:\s*grid;[^}]*width:\s*min\(920px,\s*100%\);[^}]*gap:\s*var\(--space-6\);/u
+26 -31
View File
@@ -196,48 +196,41 @@ export const settings = {
permissions: 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.', '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: { customization: {
title: 'Native Runtime customization', title: 'Capabilities and defaults',
description: description:
'Manage capabilities supplied by this Runtime. The inventory excludes Skills assigned by GoodBuddy and temporary GoodBuddy MCP servers.', 'Configure this Runtimes defaults and inspect its built-in capabilities. The inventory excludes Skills assigned by GoodBuddy and temporary MCP servers.',
refresh: 'Refresh native Runtime capabilities', refresh: 'Refresh capability inventory',
retry: 'Retry', retry: 'Retry',
loading: 'Loading native Runtime capabilities…', loading: 'Loading capability inventory…',
save: 'Save Runtime customization', unsaved:
saving: 'Saving…', 'There are unsaved Runtime customization changes. Use “Save settings” at the top right to save everything together.',
saved: 'Runtime customization saved', discard: 'Discard customization changes',
unsavedClose:
'Save or discard the Runtime customization changes before closing Settings.',
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled', disabled: 'Disabled',
errors: { errors: {
load: 'Could not load native Runtime capabilities', load: 'Could not load capability inventory',
save: 'Could not save Runtime customization' save: 'Could not save Runtime customization'
}, },
inventory: { inventory: {
tabsAriaLabel: 'Native Runtime capabilities', tabsAriaLabel: 'Capability inventory',
nativeOnly: agents: 'Agents',
'Only Runtime-native configuration and plugin capabilities are shown. GoodBuddy assignments are excluded.', tools: 'Tools',
status: { skills: 'Skills',
available: 'Native Runtime capabilities available', mcp: 'MCP',
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',
commands: 'Commands', commands: 'Commands',
rules: 'Native Rules', rules: 'Rules',
prompts: 'Prompt templates', prompts: 'Prompts',
resources: 'MCP Resources', resources: 'Resources',
lsp: 'LSP status', lsp: 'LSP',
formatters: 'Formatter status', formatters: 'Formatters',
empty: 'None detected', empty: 'None detected',
emptyDescription: 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', unsupported: 'Not supported by this Runtime',
toolsUnsupported: 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}}', toolModes: 'Ask: {{ask}} · Execute: {{execute}}',
toolKind: { toolKind: {
read: 'Read', read: 'Read',
@@ -286,12 +279,13 @@ export const settings = {
title: 'Context and compaction' title: 'Context and compaction'
}, },
opencode: { opencode: {
defaultAgent: 'Default Runtime Agent', defaultAgent: 'Default Agent',
runtimeDefault: 'Let OpenCode choose', runtimeDefault: 'Let OpenCode choose',
agentDescription: agentDescription:
'Applies only to GoodBuddy-managed local OpenCode. A conversation can still select a different Agent.' 'Applies only to GoodBuddy-managed local OpenCode. A conversation can still select a different Agent.'
}, },
continue: { continue: {
editorTitle: 'Continue configuration presets',
editPreset: 'Edit configuration preset', editPreset: 'Edit configuration preset',
noPresets: 'No presets', noPresets: 'No presets',
addPreset: 'Add preset', addPreset: 'Add preset',
@@ -321,8 +315,9 @@ export const settings = {
promptContent: '{{name}} content', promptContent: '{{name}} content',
removePrompt: 'Delete Prompt {{name}}', removePrompt: 'Delete Prompt {{name}}',
mergedRules: 'View {{count}} merged Rules', mergedRules: 'View {{count}} merged Rules',
emptyPresetTitle: 'No Continue presets yet',
emptyPreset: emptyPreset:
'Add a preset to manage Continue Rules and Prompt templates.' 'Use “Add preset” above to create Rules and Prompt templates.'
} }
}, },
advanced: 'Advanced settings', advanced: 'Advanced settings',
+26 -31
View File
@@ -175,46 +175,39 @@ export const settings = {
permissions: permissions:
'对话时可选择 Ask 或 Execute。Ask 仅可调用当前 Runtime 允许的只读能力;Execute 可调用已启用工具,调用过程会记录到活动。', '对话时可选择 Ask 或 Execute。Ask 仅可调用当前 Runtime 允许的只读能力;Execute 可调用已启用工具,调用过程会记录到活动。',
customization: { customization: {
title: 'Runtime 原生定制', title: '能力与默认配置',
description: description:
'管理当前 Runtime 自己提供的能力;清单不包含 GoodBuddy 分配的 Skills 或临时 MCP。', '设置当前 Runtime 的默认项并查看自带能力;清单不包含 GoodBuddy 分配的 Skills 或临时 MCP。',
refresh: '刷新 Runtime 原生能力', refresh: '刷新能力清单',
retry: '重试', retry: '重试',
loading: '正在读取 Runtime 原生能力…', loading: '正在读取能力清单…',
save: '保存 Runtime 定制', unsaved:
saving: '正在保存', '有未保存的 Runtime 定制更改,点击页面右上角“保存设置”统一保存',
saved: '已保存 Runtime 定制设置', discard: '撤销定制更改',
unsavedClose:
'请先保存或撤销 Runtime 定制更改,再关闭设置中心。',
enabled: '已启用', enabled: '已启用',
disabled: '已停用', disabled: '已停用',
errors: { errors: {
load: '读取 Runtime 原生能力失败', load: '读取能力清单失败',
save: '保存 Runtime 定制失败' save: '保存 Runtime 定制失败'
}, },
inventory: { inventory: {
tabsAriaLabel: 'Runtime 原生能力', tabsAriaLabel: '能力清单',
nativeOnly: agents: 'Agents',
'这里只显示 Runtime 原生配置与插件能力,不显示 GoodBuddy 分配内容。', tools: 'Tools',
status: { skills: 'Skills',
available: 'Runtime 原生能力可用', mcp: 'MCP',
partial: 'Runtime 原生能力部分可用',
unavailable: 'Runtime 原生能力不可用',
'connection-only': '仅确认 Runtime 连接',
unsupported: 'Runtime 不支持原生能力清单'
},
agents: '原生 Agents',
tools: '原生 Tools',
skills: '原生 Skills',
mcp: '原生 MCP',
commands: 'Commands', commands: 'Commands',
rules: '原生 Rules', rules: 'Rules',
prompts: 'Prompt 模板', prompts: 'Prompts',
resources: 'MCP Resources', resources: 'Resources',
lsp: 'LSP 状态', lsp: 'LSP',
formatters: 'Formatter 状态', formatters: 'Formatters',
empty: '未发现', empty: '未发现',
emptyDescription: '当前 Runtime 未报告此类别中的可用原生能力。', emptyDescription: '当前 Runtime 未报告此类别中的可用能力。',
unsupported: '当前 Runtime 不支持', unsupported: '当前 Runtime 不支持',
toolsUnsupported: '当前 Runtime 不支持静态发现原生 Tools', toolsUnsupported: '当前 Runtime 不支持静态发现 Tools',
toolModes: 'Ask{{ask}} · Execute{{execute}}', toolModes: 'Ask{{ask}} · Execute{{execute}}',
toolKind: { toolKind: {
read: '读取', read: '读取',
@@ -263,12 +256,13 @@ export const settings = {
title: '上下文与压缩' title: '上下文与压缩'
}, },
opencode: { opencode: {
defaultAgent: '默认 Runtime Agent', defaultAgent: '默认 Agent',
runtimeDefault: '由 OpenCode 选择', runtimeDefault: '由 OpenCode 选择',
agentDescription: agentDescription:
'只影响 GoodBuddy 管理的本机 OpenCode;聊天中仍可为当前对话单独选择。' '只影响 GoodBuddy 管理的本机 OpenCode;聊天中仍可为当前对话单独选择。'
}, },
continue: { continue: {
editorTitle: 'Continue 配置预设',
editPreset: '编辑配置预设', editPreset: '编辑配置预设',
noPresets: '尚无预设', noPresets: '尚无预设',
addPreset: '添加预设', addPreset: '添加预设',
@@ -295,7 +289,8 @@ export const settings = {
promptContent: '{{name}} 内容', promptContent: '{{name}} 内容',
removePrompt: '删除 Prompt {{name}}', removePrompt: '删除 Prompt {{name}}',
mergedRules: '查看最终合并的 {{count}} 条 Rule', mergedRules: '查看最终合并的 {{count}} 条 Rule',
emptyPreset: '添加一个预设后即可管理 Rules 与 Prompt 模板。' emptyPresetTitle: '还没有 Continue 预设',
emptyPreset: '使用上方“添加预设”创建 Rules 与 Prompt 模板。'
} }
}, },
advanced: '高级设置', advanced: '高级设置',
+75 -16
View File
@@ -4949,7 +4949,7 @@ button > svg {
padding: 24px 32px 32px; padding: 24px 32px 32px;
overflow: hidden; overflow: hidden;
align-items: stretch; align-items: stretch;
grid-template-columns: 190px minmax(0, 760px); grid-template-columns: 190px minmax(0, 1fr);
justify-content: start; justify-content: start;
gap: 28px; gap: 28px;
} }
@@ -4986,6 +4986,7 @@ button > svg {
} }
.settings-page .settings-panel__content { .settings-page .settings-panel__content {
width: min(100%, var(--content-standard));
min-height: 0; min-height: 0;
padding-right: var(--space-1); padding-right: var(--space-1);
overflow-y: auto; overflow-y: auto;
@@ -5048,9 +5049,16 @@ button > svg {
text-align: left; text-align: left;
} }
.settings-page .settings-tabs button strong {
font-size: var(--font-body);
line-height: 1.35;
}
.settings-page .settings-tabs button small { .settings-page .settings-tabs button small {
display: block; display: block;
margin-top: 3px; margin-top: 3px;
font-size: var(--font-caption);
line-height: 1.35;
} }
.settings-page .settings-tabs button[aria-selected='true'] { .settings-page .settings-tabs button[aria-selected='true'] {
@@ -5306,14 +5314,25 @@ button > svg {
.runtime-customization-section__header > div { .runtime-customization-section__header > div {
min-width: 0; min-width: 0;
} display: flex;
.runtime-customization-section__header > div {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: var(--space-1); 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 { .runtime-customization-section__error {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5332,18 +5351,22 @@ button > svg {
.runtime-customization-editor { .runtime-customization-editor {
min-width: 0; min-width: 0;
margin: 0; margin: 0;
padding: var(--space-4); padding: 0;
border: 1px solid var(--border-default); border: 0;
border-radius: var(--radius-card); background: transparent;
background: var(--surface-subtle);
} }
.runtime-native-inventory__status { .runtime-native-inventory__status {
display: grid; display: flex;
align-items: center;
min-height: 42px;
padding: var(--space-3); padding: var(--space-3);
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
border-radius: var(--radius-control); 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 { .runtime-native-inventory__status--available {
@@ -5363,13 +5386,16 @@ button > svg {
background: var(--warning-subtle); 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); color: var(--text-secondary);
overflow-wrap: anywhere; flex-wrap: wrap;
} font-size: var(--font-caption);
gap: var(--space-3);
.runtime-customization-section__actions {
justify-content: flex-end;
} }
.runtime-native-inventory > .page-tabs { .runtime-native-inventory > .page-tabs {
@@ -7419,6 +7445,39 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--text-secondary); 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 { .conversation-row {
position: relative; position: relative;
display: flex; display: flex;
+13 -4
View File
@@ -251,22 +251,31 @@ export const conversationContextMetricsSchema = z
.object({ .object({
runtimeSelectionKey: z.string().trim().min(1).max(1_000), runtimeSelectionKey: z.string().trim().min(1).max(1_000),
contextTokens: z.number().int().nonnegative().max(50_000_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 effectiveTriggerTokens: z
.number() .number()
.int() .int()
.nonnegative() .nonnegative()
.max(10_000_000), .max(10_000_000)
.optional(),
contextWindowTokens: z contextWindowTokens: z
.number() .number()
.int() .int()
.nonnegative() .nonnegative()
.max(10_000_000) .max(10_000_000)
.optional(), .optional(),
compressionEnabled: z.boolean(), compressionEnabled: z.boolean().optional(),
source: z.enum(['provider', 'estimated']),
basis: z.enum(['model-call', 'conversation']).optional()
}) })
.strict() .strict()
.transform((metrics) => ({
runtimeSelectionKey: metrics.runtimeSelectionKey,
contextTokens: metrics.contextTokens,
source: metrics.source,
basis: metrics.basis
}))
export type ConversationContextMetrics = z.infer< export type ConversationContextMetrics = z.infer<
typeof conversationContextMetricsSchema typeof conversationContextMetricsSchema