From a815a7f2a99f0e6f97c21c0ebcdde107847979b0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 17:58:29 +0800 Subject: [PATCH 001/121] feat(web): open action, badge and section slots on plugin detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plugins page declares three list slots as children of its main entry: `plugins.detail.actions` renders at the head of a detail page before its switch and uninstall controls, `plugins.detail.badge` after the tags beside the title, and `plugins.detail.section` below the page's own content. Every entry receives a `subject` owner prop naming what the open page is about — a bundle, one row of a bundle, or an official item — so one entry serves any page and renders nothing elsewhere. A row's page still opens only for rows with a `plugins.row.config` entry. The `plugin-config` web lane drives the three slots through the live fixture on its bundle page and its row page, records a new bundle golden, and shows the fixture contributing nothing to an official page. --- ...ugin-detail-page-extension-slots.i18n.yaml | 6 + ...9-17-plugin-detail-page-extension-slots.md | 35 ++++ ...7-plugin-detail-page-extension-slots.zh.md | 35 ++++ .../expected/plugin-config/bundle.expected.md | 25 +++ .../expected/plugin-config/row.expected.md | 5 + .../plugins/fixture-live-client/client.js | 32 +++- apps/web/tests/plugin-config.e2e.ts | 33 +++- docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 3 + docs/subsystems/slots.zh.md | 3 + .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 14 ++ .../client/ui-plugin-manager/README.zh.md | 14 ++ .../src/client/PluginManagerPage.tsx | 66 ++++++- .../ui-plugin-manager/src/client/index.ts | 7 +- .../src/client/slot-contract.ts | 80 +++++++-- .../tests/browser-plugin.client.spec.tsx | 3 + .../tests/components.client.spec.tsx | 92 +++++++++- .../src/client/slot-catalog.ts | 166 +++++++++++++++++- 19 files changed, 583 insertions(+), 44 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md create mode 100644 apps/web/tests/expected/plugin-config/bundle.expected.md diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml new file mode 100644 index 0000000000..5bd9f6000c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md +2026-09-17-plugin-detail-page-extension-slots.md: d4bbca0c4466fbb78a8c56cfc4e327100385368f +2026-09-17-plugin-detail-page-extension-slots.zh.md: f07efc3d3cadded04a7551867680cc058d532943 diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md new file mode 100644 index 0000000000..d4bbca0c44 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md @@ -0,0 +1,35 @@ +# Agent Note: Plugin detail pages as an extension point + +Status: implemented + +English | [中文](2026-09-17-plugin-detail-page-extension-slots.zh.md) + +## Problem + +The Plugins page's detail pages — a bundle's page, a row's page, an official plugin's page — carried only what the page drew itself and the object's own configuration ([plugin configuration on the Plugins page](2026-09-16-plugin-configuration-on-the-plugins-page.md)). A plugin with something to say about a bundle it does not own — a diagnostics plugin's health check, a market plugin's update badge, a documentation plugin's README section — had no place on that bundle's page, and importing the page to extend it is forbidden by the client bundle purity rule. + +## Decision + +**The page declares three list slots keyed by region, not by object.** `plugins.detail.actions` renders at the head of a page before the page's own switch and uninstall; `plugins.detail.badge` beside the title after the version, beta, and problem tags; `plugins.detail.section` under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Each entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`. An entry decides from the subject whether to render and returns null otherwise; the page orders entries by `order`. + +**The subject is a projection, not the store.** `PluginPackageRef` and `PluginRowRef` carry the name, version, installed and enabled facts, and the row list — the facts a contribution decides on — and nothing of the page's own state, so the page's store stays private and the contract stays small. + +**A row's page still exists only for a configured row.** A list entry cannot say in advance which rows it has something for, and a row page with nothing on it shows nothing worth opening, so the configure control keeps its rule: a row is opened when a `plugins.row.config` entry names it, and contributions for the row render on that page. + +## Alternatives considered + +**One slot per object and region (`plugins.bundle.section`, `plugins.row.section`, ...).** Rejected: nine slots for three regions, and a contribution that spans objects would register three times. The subject discriminator carries the same information in three slots. + +**Passing the page's `PackageView` and `PackageRow` as the subject.** Rejected: those types carry the Host's read-only reasons, errors, entry ids, and fiber phases, which are the page's business; exposing them would make store internals a public contract. + +**Every row gets a page so row contributions always have a home.** Deferred: a row without configuration has nothing a page would show beyond its module and state; the rule can widen when a contribution needs it. + +## Consequences + +- A contribution is a slot registration with `import type` of `ui-plugin-manager` for the declarations; the page never names a contributing plugin. +- The e2e fixture bundle (`@fixture/live-client`) contributes to its own bundle and row pages through the three slots; its browser half registers them with the rest, and they leave with it. +- The slot catalog and the slots subsystem hierarchy list the three slots under `main`. + +## Testing + +`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` renders contributions on a bundle's page, a row's page, and an official plugin's page with the subject each is about, and pins the projection; `browser-plugin.client.spec.tsx` pins the declarations. `apps/web/tests/plugin-config.e2e.ts` switches the fixture bundle on and asserts its action, badge, and section on the bundle's page and the row's page, their absence on an official plugin's page, and holds the bundle page's golden. diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md new file mode 100644 index 0000000000..f07efc3d3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md @@ -0,0 +1,35 @@ +# Agent Note:插件详情页作为扩展点 + +状态:已实现 + +[English](2026-09-17-plugin-detail-page-extension-slots.md) | 中文 + +## 问题 + +插件页的详情页——组合包页、行页、官方插件页——只承载页面自绘的内容和对象自己的配置([插件配置放在插件页](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md))。对不属于自己的组合包有话要说的插件——诊断插件的健康检查、市场插件的更新标签、文档插件的 README 区块——在那个组合包的页面上无处安放,而通过 import 页面来扩展它被客户端包纯净性规则禁止。 + +## 决策 + +**页面按区域而不是按对象声明三个 list slot。** `plugins.detail.actions` 渲染在页头,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下——组合包页在组件列表之后,行页与官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`。条目根据 subject 决定是否渲染,否则返回 null;页面按 `order` 排列条目。 + +**subject 是投影,不是 store。** `PluginPackageRef` 与 `PluginRowRef` 携带包名、版本、是否已安装、是否启用以及行列表——贡献者据以判断的事实——不带页面自己的任何状态,因此页面的 store 保持私有,契约保持精简。 + +**行页仍然只为已配置的行存在。** list 条目无法预先说明它对哪些行有话要说,而一个空空如也的行页没有值得打开的内容,所以配置控件保留原规则:有 `plugins.row.config` 条目点名这一行时才可打开,对该行的贡献渲染在那个页面上。 + +## 考虑过的替代方案 + +**按对象和区域各开一个 slot(`plugins.bundle.section`、`plugins.row.section` 等)。** 否决:三个区域要九个 slot,而跨对象的贡献要注册三次。subject 判别式在三个 slot 里承载同样的信息。 + +**把页面的 `PackageView` 与 `PackageRow` 直接作为 subject。** 否决:这些类型携带 Host 的只读原因、错误、条目 id 与 fiber 阶段,这些是页面自己的事;暴露它们会让 store 内部成为公共契约。 + +**每一行都有页面,让行级贡献总有落脚处。** 推迟:没有配置的行,页面除了模块名和状态之外无可展示;等有贡献需要时再放宽这条规则。 + +## 后果 + +- 一个贡献就是一次 slot 注册,只需 `import type` `ui-plugin-manager` 的声明;页面从不点名任何贡献插件。 +- e2e 夹具组合包(`@fixture/live-client`)通过三个 slot 向它自己的组合包页和行页贡献内容;它的浏览器半侧与其余注册一起完成,也一起离开。 +- slot 目录与 slots 子系统层级树在 `main` 下列出这三个 slot。 + +## 测试 + +`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` 在组合包页、行页和官方插件页上渲染贡献并核对各自的 subject,钉住投影;`browser-plugin.client.spec.tsx` 钉住声明。`apps/web/tests/plugin-config.e2e.ts` 打开夹具组合包,断言组合包页与行页上的操作、标签和区块、官方插件页上没有它们,并保留组合包页的 golden。 diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md new file mode 100644 index 0000000000..23985c8c66 --- /dev/null +++ b/apps/web/tests/expected/plugin-config/bundle.expected.md @@ -0,0 +1,25 @@ +- button "返回插件列表": + - img + - text: 插件列表 +- button "夹具操作" +- button "卸载 live-client": + - img + - text: 卸载 +- switch "启用 live-client" [checked] +- heading "live-client" [level=3] +- text: v0.0.1 夹具标签 +- paragraph: + - code: "@fixture/live-client" +- paragraph: 暂无描述。 +- heading "包含的组件" [level=4] +- text: 共 1 个 · 1 运行中 +- list: + - listitem: + - button "配置 fixture-live-client": + - text: fixture-live-client + - img + - text: "@fixture/live-client 运行中" + - switch "启用组件 fixture-live-client" [checked] +- region "夹具区块": + - heading "夹具区块" [level=4] + - paragraph: 来自夹具的区块内容 diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md index a68510932f..839c9d630f 100644 --- a/apps/web/tests/expected/plugin-config/row.expected.md +++ b/apps/web/tests/expected/plugin-config/row.expected.md @@ -1,7 +1,9 @@ - button "返回 live-client": - img - text: live-client +- button "夹具操作" - heading "fixture-live-client" [level=3] +- text: 夹具标签 - paragraph: - code: "@fixture/live-client" - paragraph: 示例配置项 @@ -9,3 +11,6 @@ - text: 问候语 - textbox "问候语": 你好 - button "保存" +- region "夹具区块": + - heading "夹具区块" [level=4] + - paragraph: 来自夹具的区块内容 diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js index d870a5e52a..e2dd74c8f4 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js @@ -13,9 +13,37 @@ window.__ModuleLoader__.load({ const counters = document.documentElement.dataset counters.liveMounts = String(Number(counters.liveMounts ?? 0) + 1) ctx.effect(() => ctx.locale.register('fixtureLive', { - zh: { active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存' }, - en: { active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save' }, + zh: { + active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存', + action: '夹具操作', badge: '夹具标签', section: '夹具区块', sectionBody: '来自夹具的区块内容', + }, + en: { + active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save', + action: 'Fixture action', badge: 'Fixture badge', section: 'Fixture section', sectionBody: 'Section content from the fixture', + }, })) + // Detail contributions for this bundle's own page and its row's page: an + // action at the head, a badge beside the title, and a section below. + // The subject says what the open page is about; another bundle's page + // gets nothing from these entries. + const mine = (subject) => (subject.kind === 'bundle' || subject.kind === 'row') && subject.pkg.name === '@fixture/live-client' + ctx.slots.inject('plugins.detail.actions', () => ctx.slots.register({ + name: 'plugins.detail.actions', id: 'fixture-live-client', locale: 'fixtureLive', + }, ({ t, subject }) => mine(subject) + ? React.createElement('button', { type: 'button', 'data-live-action': subject.kind }, t('action')) + : null)) + ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ + name: 'plugins.detail.badge', id: 'fixture-live-client', locale: 'fixtureLive', + }, ({ t, subject }) => mine(subject) + ? React.createElement('span', { 'data-live-badge': subject.kind }, t('badge')) + : null)) + ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ + name: 'plugins.detail.section', id: 'fixture-live-client', locale: 'fixtureLive', + }, ({ t, subject }) => mine(subject) + ? React.createElement('section', { 'data-live-section': subject.kind, 'aria-label': t('section') }, + React.createElement('h4', null, t('section')), + React.createElement('p', null, t('sectionBody'))) + : null)) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive', }, ({ t }) => React.createElement('div', { 'data-live-client': '' }, t('active')))) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index db20eed409..7266b766a7 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -20,6 +20,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-config', import.meta.url)) const OFFICIAL_EXPECTED = join(SNAPSHOT_DIR, 'official.expected.md') const ROW_EXPECTED = join(SNAPSHOT_DIR, 'row.expected.md') +const BUNDLE_EXPECTED = join(SNAPSHOT_DIR, 'bundle.expected.md') const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) const MODE = webSnapshotMode() @@ -291,17 +292,37 @@ describe('web e2e: plugin configuration pages', () => { await row.waitFor({ timeout: 10_000 }) expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0) + // Off, the bundle's page carries no contribution either: the browser half + // that would make them is not loaded. + const bundlePage = panel.locator('[data-plugin-detail="@fixture/live-client"]') + expect(await bundlePage.locator('[data-live-action]').count()).toBe(0) + // Switched on, the Host recomposes and the browser half mounts without a - // reload; its registration puts the configure control on the row. + // reload; its registration puts the configure control on the row, and + // its detail contributions on the bundle's page: the action before the + // page's own switch, the badge beside the title, the section after the rows. await panel.getByRole('switch', { name: '启用 live-client' }).click() const configure = panel.getByRole('button', { name: '配置 fixture-live-client' }) await configure.waitFor({ timeout: 30_000 }) + await bundlePage.locator('[data-live-section="bundle"]').waitFor({ timeout: 10_000 }) + // The page's own view of the row follows the Host's change event, which + // can land after the browser half mounted; the golden holds the settled page. + await row.getByText('运行中', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await bundlePage.getByRole('button', { name: '夹具操作' }).count()).toBe(1) + expect(await bundlePage.locator('[data-live-badge="bundle"]').textContent()).toBe('夹具标签') + expect(await bundlePage.getByRole('region', { name: '夹具区块' }).getByText('来自夹具的区块内容').count()).toBe(1) + const bundleSnapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) + await compareOrRefreshGolden(BUNDLE_EXPECTED, bundleSnapshot, MODE) await configure.click() const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]') await rowPage.waitFor({ timeout: 10_000 }) expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client') expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1) + // The same entries render for the row's page, told it is about the row. + expect(await rowPage.locator('[data-live-action="row"]').count()).toBe(1) + expect(await rowPage.locator('[data-live-badge="row"]').count()).toBe(1) + expect(await rowPage.locator('[data-live-section="row"]').count()).toBe(1) const form = rowPage.getByRole('form', { name: '动态插件配置' }) await form.getByLabel('问候语').fill('你好') await form.getByRole('button', { name: '保存' }).click() @@ -310,12 +331,18 @@ describe('web e2e: plugin configuration pages', () => { const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE) await rowPage.getByRole('button', { name: '返回 live-client' }).click() - await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 }) + await bundlePage.waitFor({ timeout: 10_000 }) + + // An official plugin's page is another subject; the fixture's entries render nothing for it. + await panel.getByRole('button', { name: '返回插件列表' }).click() + await openPage(panel, '终端') + const itemPage = panel.locator('[data-plugin-item-detail]') + expect(await itemPage.locator('[data-live-action], [data-live-badge], [data-live-section]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 90_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['official.expected.md', 'row.expected.md', 'subagent.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['bundle.expected.md', 'official.expected.md', 'row.expected.md', 'subagent.expected.md']) }) }) diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index d25c088405..4dff9db725 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: 177bbc5b8f56b7276f271a2ddb7ac71ac8888363 -slots.zh.md: c746ac88704c2e22e5c50ef82a893fde9f091668 +slots.md: 31ccf545dc9d0e4a8df88dd9a6aa24541686fb2f +slots.zh.md: 734daa4919e9b0a9cba74d6c523011fd37ce26ab diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 177bbc5b8f..31ccf545dc 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -132,6 +132,9 @@ root │ ├─ plugins.item │ ├─ plugins.bundle.config │ ├─ plugins.row.config +│ ├─ plugins.detail.actions +│ ├─ plugins.detail.badge +│ ├─ plugins.detail.section │ └─ main.conversation │ ├─ conversation.session │ │ └─ conversation.view diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c746ac8870..734daa4919 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -132,6 +132,9 @@ root │ ├─ plugins.item │ ├─ plugins.bundle.config │ ├─ plugins.row.config +│ ├─ plugins.detail.actions +│ ├─ plugins.detail.badge +│ ├─ plugins.detail.section │ └─ main.conversation │ ├─ conversation.session │ │ └─ conversation.view diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index e50cdb00de..795636bb9f 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: e7128dd2146cc0553ab36fdef5c31470be7877f2 -README.zh.md: ff7706f1d9747f553bf91f45a76fa5f50472330d +README.md: 4b6c6752f8dc71f7c00324cf56d236631810f752 +README.zh.md: f7a9ebe9efba83e6b9f5fb46b6422c2e099ead04 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index e7128dd214..4b6c6752f8 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -57,6 +57,20 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ The bundle's patch must declare the row under that id, and the registration exists while the bundle is on, so a bundle that is off shows no configure control. +### Detail page extension points + +A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, beta, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. + +```tsx ignore-check +ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ + name: 'plugins.detail.section', + id: 'acme-health', + locale: 'acmeHealth', +}, ({ t, subject }) => subject.kind === 'bundle' ? : null)) +``` + +A row's page exists only while a `plugins.row.config` entry names the row, so a contribution meant for a row renders on the page that configuration opens. + ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index ff7706f1d9..f7a9ebe9ef 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -57,6 +57,20 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ 组合包的 patch 必须以该 id 声明这一行;注册只在组合包开启期间存在,因此关闭的组合包不显示配置控件。 +### 详情页扩展点 + +对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 + +```tsx ignore-check +ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ + name: 'plugins.detail.section', + id: 'acme-health', + locale: 'acmeHealth', +}, ({ t, subject }) => subject.kind === 'bundle' ? : null)) +``` + +行页只在某个 `plugins.row.config` 条目点名这一行时存在,因此给行的贡献渲染在该配置打开的页面上。 + ----- diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index a7cec19607..d118e672ab 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -26,14 +26,17 @@ import { type PluginManagerFace, } from './manager-store.ts' import { managementText, noticeText, packageText, type Translate } from './presentation.ts' -import type {} from './slot-contract.ts' +import type { PluginPackageRef, PluginRowRef, PluginsSubject } from './slot-contract.ts' import css from './PluginManagerPage.module.css' /** Full component props assembled by the main slot renderer. */ export type PluginManagerPageProps = PropsRuntime<'main'> & PropsLocale<'pluginManager'> - & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config'> + & PropsRenderSlots< + | 'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' + | 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section' + > & InjectFace /** The page's slot renderer, narrowed to the configuration slots. */ @@ -343,24 +346,54 @@ function ItemCard({ item, t, onOpen, renderSlot }: { ) } -/** An official plugin's page: the crumb back to the cards, its icon, its title over its one-liner, and the form the entry renders. */ +/** One row as the detail slots see it. */ +function rowRef(row: PackageRow): PluginRowRef { + return { rowId: row.rowId, moduleName: row.moduleName, enabled: row.enabled } +} + +/** One bundle as the detail slots see it. */ +function packageRef(pkg: PackageView): PluginPackageRef { + return { + name: pkg.name, + ...pkg.version === undefined ? {} : { version: pkg.version }, + installed: pkg.installed, + enabled: pkg.enabled, + rows: pkg.rows.map(rowRef), + } +} + +/** + * An official plugin's page: the crumb back to the cards, its icon with the + * contributed actions, its title with the contributed badges over its + * one-liner, the form the entry renders, and the contributed sections. + */ function ItemDetail({ item, t, onBack, renderSlot }: { readonly item: OfficialItem readonly t: Translate readonly onBack: () => void readonly renderSlot: RenderConfig }): ReactNode { + const subject: PluginsSubject = { kind: 'item', id: item.id } return (
- + {renderSlot('plugins.detail.actions', { subject })}
} + />

{item.label}

+ {renderSlot('plugins.detail.badge', { subject })}

{renderSlot('plugins.item', { view: 'summary' }, { only: item.id })}

-
- {renderSlot('plugins.item', { view: 'page' }, { only: item.id })} +
+
+ {renderSlot('plugins.item', { view: 'page' }, { only: item.id })} +
+ {renderSlot('plugins.detail.section', { subject })}
) @@ -379,18 +412,29 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: { }): ReactNode { const { title } = packageText(pkg, t) const key = rowConfigKey(pkg.name, row.rowId) + const subject: PluginsSubject = { kind: 'row', pkg: packageRef(pkg), row: rowRef(row) } return (
- } /> + } + actions={
{renderSlot('plugins.detail.actions', { subject })}
} + />

{row.rowId}

+ {renderSlot('plugins.detail.badge', { subject })}

{row.moduleName}

{renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}

-
- {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })} +
+
+ {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })} +
+ {renderSlot('plugins.detail.section', { subject })}
) @@ -424,6 +468,7 @@ function PackageDetail({ }): ReactNode { const { title, description, beta } = packageText(pkg, t) const status = packageStatus(pkg) + const subject: PluginsSubject = { kind: 'bundle', pkg: packageRef(pkg) } return (
+ {renderSlot('plugins.detail.actions', { subject })} {pkg.installed ? (

{pkg.name}

{description ?? t('noDescription')}

@@ -477,6 +524,7 @@ function PackageDetail({ toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} configure={configure} /> + {renderSlot('plugins.detail.section', { subject })}
) diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 1e2c9a60b1..2303b2609b 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -31,7 +31,9 @@ export type { PluginManagerPageProps } from './PluginManagerPage.tsx' export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' export type { PluginManagerLocaleKey } from './locales.ts' -export type { PluginConfigViewProps } from './slot-contract.ts' +export type { + PluginConfigViewProps, PluginDetailProps, PluginPackageRef, PluginRowRef, PluginsSubject, +} from './slot-contract.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -89,6 +91,9 @@ export function apply(ctx: ClientContext): void { 'plugins.item': { kind: 'list', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, }, }, PluginManagerPage)) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index 2fd0b41d40..3a371375f8 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -1,13 +1,19 @@ /** - * The slots the Plugins page declares for plugins that carry their own - * configuration: the official plugins listed beside the official bundles, one - * bundle's configuration on its page, and one row's configuration opened from - * that row. A registrant merges this contract with `import type` and registers - * through `ctx.slots`; it never imports this package at runtime. + * The slots the Plugins page declares for other plugins. Three carry a + * plugin's own configuration: the official plugins listed beside the official + * bundles, one bundle's configuration on its page, and one row's configuration + * opened from that row. Three more take contributions to any detail page — + * its head actions, the badges beside its title, and the sections under its + * own content — from a plugin that has something to say about bundles, rows, + * or official plugins it does not own. A registrant merges this contract with + * `import type` and registers through `ctx.slots`; it never imports this + * package at runtime. * - * Every entry is rendered in two views the owner asks for: `summary` for the - * one-liner the page places under the title, `page` for the form with its own - * save control. The page draws the title, the icon, and the crumb itself. + * A configuration entry is rendered in two views the owner asks for: + * `summary` for the one-liner the page places under the title, `page` for the + * form with its own save control. A detail contribution is rendered with the + * subject of the open page and decides from it whether to render at all. The + * page draws the title, the icon, and the crumb itself. */ import type {} from '@deepseek-ai/dsh-client-ui-slots' @@ -18,6 +24,42 @@ export interface PluginConfigViewProps { readonly view: 'summary' | 'page' } +/** One row of a bundle as a detail contribution sees it. */ +export interface PluginRowRef { + /** The row id as the bundle's patch declares it. */ + readonly rowId: string + /** The module the row names. */ + readonly moduleName: string + /** Whether the row's entry runs. */ + readonly enabled: boolean +} + +/** One bundle as a detail contribution sees it: the facts a contribution decides on, never the page's own state. */ +export interface PluginPackageRef { + /** The package name. */ + readonly name: string + /** The installed version, when the Host reports one. */ + readonly version?: string + /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ + readonly installed: boolean + /** Whether the bundle is switched on. */ + readonly enabled: boolean + /** The rows the bundle declares. */ + readonly rows: readonly PluginRowRef[] +} + +/** What a detail page is about: a bundle, one row of a bundle, or an official plugin listed by its `plugins.item` id. */ +export type PluginsSubject = + | { readonly kind: 'bundle'; readonly pkg: PluginPackageRef } + | { readonly kind: 'row'; readonly pkg: PluginPackageRef; readonly row: PluginRowRef } + | { readonly kind: 'item'; readonly id: string } + +/** The owner props every detail contribution is rendered with. */ +export interface PluginDetailProps { + /** The subject of the open page; an entry renders null for a subject it has nothing for. */ + readonly subject: PluginsSubject +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** @@ -25,8 +67,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * the official bundles: `label` is the card's title and `order` its place. * The page renders the entry as the card's one-liner (`view: 'summary'`) * and, once the card is opened, as the body of the plugin's own page - * (`view: 'page'`). OCCUPIED by the host-plane configuration pages - * `ui-settings-plugins` ships; a bundle's configuration belongs in + * (`view: 'page'`). OCCUPIED by the official settings pages, one companion + * package per host-plane namespace; a bundle's configuration belongs in * `plugins.bundle.config` or `plugins.row.config` instead. */ 'plugins.item': { kind: 'list'; scope: 'root'; owner: PluginConfigViewProps } @@ -43,5 +85,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * the entry's page, headed by the row id and the entry's summary. */ 'plugins.row.config': { kind: 'keyed'; scope: 'root'; owner: PluginConfigViewProps } + /** + * Controls at the head of a detail page, before the page's own switch and + * uninstall, rendered with the page's subject. An entry renders null for a + * subject it has no control for. + */ + 'plugins.detail.actions': { kind: 'list'; scope: 'root'; owner: PluginDetailProps } + /** + * Tags beside a detail page's title, after the version, beta, and problem + * tags the page draws itself, rendered with the page's subject. + */ + 'plugins.detail.badge': { kind: 'list'; scope: 'root'; owner: PluginDetailProps } + /** + * Sections under a detail page's own content: after the rows on a bundle's + * page, after the configuration on a row's or an official plugin's page. + * An entry draws its own section chrome and renders null for a subject it + * has nothing for. + */ + 'plugins.detail.section': { kind: 'list'; scope: 'root'; owner: PluginDetailProps } } } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index af7da41774..2c768e5d17 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -81,6 +81,9 @@ describe('ui-plugin-manager browser plugin', () => { expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' }) expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) + for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) { + expect(b.slots.spec(name)).toMatchObject({ kind: 'list', scope: 'root' }) + } const face = (entry.inject as unknown as () => PluginManagerFace)() expect(face.hooks.configLedger.getSnapshot()).toEqual({ items: [], bundles: new Set(), rows: new Set() }) // A Host change before the first render is not a reason to read. diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index bca0c579da..6ec9cd78a0 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -10,6 +10,7 @@ import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' +import type { PluginsSubject } from '../src/client/slot-contract.ts' afterEach(cleanup) @@ -52,8 +53,17 @@ const READY: PluginManagerState = { highlight: null, } -/** Configuration entries a test supplies: what each slot cell renders, by `:` and the view asked for. */ -type SlotBodies = Record ReactNode> +/** + * The owner props the page passes a slot: the view it asks a configuration + * entry for, or the subject a detail contribution is rendered with. + */ +interface SlotOwner { + readonly view?: 'summary' | 'page' + readonly subject?: PluginsSubject +} + +/** Slot entries a test supplies: what each slot cell renders, by `:` (a list slot's cell is empty) and the owner props. */ +type SlotBodies = Record ReactNode> const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() } @@ -85,8 +95,8 @@ function renderTab(state: Partial = {}, config: Partial - bodies[`${name}:${opts.only ?? opts.entryKey ?? ''}`]?.(owner.view) ?? null, + renderSlot: (name: string, owner: SlotOwner, opts: { only?: string; entryKey?: string } = {}) => + bodies[`${name}:${opts.only ?? opts.entryKey ?? ''}`]?.(owner) ?? null, } as unknown as PluginManagerPageProps const { rerender } = render() return { @@ -238,9 +248,9 @@ describe('PluginManagerPage', () => { describe('configuration pages', () => { const bodies: SlotBodies = { - 'plugins.item:bash': view => view === 'summary' ? 'Limits every command.' :
, - 'plugins.bundle.config:dsh-better-sidebar': view => view === 'page' ? : null, - 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'The sidebar row.' : , + 'plugins.item:bash': ({ view }) => view === 'summary' ? 'Limits every command.' : , + 'plugins.bundle.config:dsh-better-sidebar': ({ view }) => view === 'page' ? : null, + 'plugins.row.config:dsh-better-sidebar#sidebar': ({ view }) => view === 'summary' ? 'The sidebar row.' : , } it('lists an official plugin after the official bundles with its summary, and opens its page', () => { @@ -297,6 +307,74 @@ describe('PluginManagerPage', () => { }) }) + describe('detail contributions', () => { + const subjects: PluginsSubject[] = [] + const label = (owner: SlotOwner): string => { + const subject = owner.subject + if (subject === undefined) return 'none' + subjects.push(subject) + if (subject.kind === 'bundle') return `bundle ${subject.pkg.name}` + if (subject.kind === 'row') return `row ${subject.pkg.name}#${subject.row.rowId}` + return `item ${subject.id}` + } + const bodies: SlotBodies = { + 'plugins.item:bash': ({ view }) => view === 'summary' ? 'Limits every command.' : , + 'plugins.row.config:dsh-better-sidebar#sidebar': ({ view }) => view === 'summary' ? 'The sidebar row.' : , + 'plugins.detail.actions:': owner => , + 'plugins.detail.badge:': owner => {`badge ${label(owner)}`}, + 'plugins.detail.section:': owner =>
, + } + + it('renders the contributed actions, badges, and sections on each page, told what the page is about', () => { + renderTab( + { packages: [pkg({ rows: [row()] })] }, + { items: [{ id: 'bash', label: 'Shell' }], rows: new Set(['dsh-better-sidebar#sidebar']) }, + bodies, + ) + // The cards carry none of it. + expect(screen.queryByRole('button', { name: /^act / })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + const act = within(detail).getByRole('button', { name: 'act bundle dsh-better-sidebar' }) + expect(within(detail).getByText('badge bundle dsh-better-sidebar')).toBeTruthy() + const section = within(detail).getByRole('region', { name: 'section bundle dsh-better-sidebar' }) + // The contributed actions come before the page's own switch; the sections after the rows. + const toggle = within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') }) + expect(act.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + const rows = detail.querySelector('[data-plugin-rows]') as HTMLElement + expect(rows.compareDocumentPosition(section) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + // A contribution sees the bundle's facts, not the page's own state. + expect(subjects.at(-1)).toEqual({ + kind: 'bundle', + pkg: { name: 'dsh-better-sidebar', version: '0.16.0', installed: true, enabled: true, rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true }] }, + }) + + fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') })) + const page = document.querySelector('[data-plugin-row-detail]') as HTMLElement + expect(within(page).getByRole('button', { name: 'act row dsh-better-sidebar#sidebar' })).toBeTruthy() + expect(within(page).getByText('badge row dsh-better-sidebar#sidebar')).toBeTruthy() + expect(within(page).getByRole('region', { name: 'section row dsh-better-sidebar#sidebar' })).toBeTruthy() + expect(subjects.at(-1)).toMatchObject({ kind: 'row', row: { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true } }) + fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'better-sidebar') })) + fireEvent.click(screen.getByRole('button', { name: en.backToList })) + + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') })) + const item = document.querySelector('[data-plugin-item-detail="bash"]') as HTMLElement + expect(within(item).getByRole('button', { name: 'act item bash' })).toBeTruthy() + expect(within(item).getByText('badge item bash')).toBeTruthy() + expect(within(item).getByRole('region', { name: 'section item bash' })).toBeTruthy() + expect(within(item).getByRole('form', { name: 'bash form' })).toBeTruthy() + }) + + it('leaves the version out of a bundle the Host reports none for', () => { + const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, enabled: true, rows: [] } + renderTab({ packages: [unversioned] }, {}, bodies) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, enabled: true, rows: [] } }) + }) + }) + it('preserves metadata for another scope with the same short name', () => { const name = '@acme/dsh-experimental-agent-team-profile' const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] }) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 3c040fd03d..09ed715e1a 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1575,14 +1575,164 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:38', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:80', + }, + { + key: 'plugins.detail.actions', + kind: 'list', + scope: 'root', + summary: 'Controls at the head of a detail page, before the page\'s own switch and uninstall, rendered with the page\'s subject.', + doc: 'Controls at the head of a detail page, before the page\'s own switch and\nuninstall, rendered with the page\'s subject. An entry renders null for a\nsubject it has no control for.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.actions\', () => ctx.slots.register(\n { name: \'plugins.detail.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:93', + }, + { + key: 'plugins.detail.badge', + kind: 'list', + scope: 'root', + summary: 'Tags beside a detail page\'s title, after the version, beta, and problem tags the page draws itself, rendered with the page\'s subject.', + doc: 'Tags beside a detail page\'s title, after the version, beta, and problem\ntags the page draws itself, rendered with the page\'s subject.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.badge\', () => ctx.slots.register(\n { name: \'plugins.detail.badge\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:98', + }, + { + key: 'plugins.detail.section', + kind: 'list', + scope: 'root', + summary: 'Sections under a detail page\'s own content: after the rows on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.', + doc: 'Sections under a detail page\'s own content: after the rows on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.section\', () => ctx.slots.register(\n { name: \'plugins.detail.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:105', }, { key: 'plugins.item', kind: 'list', scope: 'root', summary: 'One official plugin the Plugins page lists in its Official group after the official bundles: `label` is the card\'s title and `order` its place.', - doc: 'One official plugin the Plugins page lists in its Official group after\nthe official bundles: `label` is the card\'s title and `order` its place.\nThe page renders the entry as the card\'s one-liner (`view: \'summary\'`)\nand, once the card is opened, as the body of the plugin\'s own page\n(`view: \'page\'`). OCCUPIED by the host-plane configuration pages\n`ui-settings-plugins` ships; a bundle\'s configuration belongs in\n`plugins.bundle.config` or `plugins.row.config` instead.', + doc: 'One official plugin the Plugins page lists in its Official group after\nthe official bundles: `label` is the card\'s title and `order` its place.\nThe page renders the entry as the card\'s one-liner (`view: \'summary\'`)\nand, once the card is opened, as the body of the plugin\'s own page\n(`view: \'page\'`). OCCUPIED by the official settings pages, one companion\npackage per host-plane namespace; a bundle\'s configuration belongs in\n`plugins.bundle.config` or `plugins.row.config` instead.', registerOptions: [ { name: 'id', @@ -1621,14 +1771,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', occupants: [ - 'client-ui-settings-plugins BashCard id \'bash\'', - 'client-ui-settings-plugins AgentLoopCard id \'agent-loop\'', - 'client-ui-settings-plugins SubagentCard id \'subagent\'', - 'client-ui-settings-plugins WebSearchCard id \'web-search\'', + 'client-ui-settings-agent-loop AgentLoopCard id \'agent-loop\'', + 'client-ui-settings-shell ShellCard id \'shell\'', + 'client-ui-settings-subagent SubagentCard id \'subagent\'', + 'client-ui-settings-web-search WebSearchCard id \'web-search\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:32', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:74', }, { key: 'plugins.row.config', @@ -1664,7 +1814,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:45', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:87', }, { key: 'rightbar', From 93e67201ddb1525e88c9bc9d274cb244f697fbc0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 17:59:12 +0800 Subject: [PATCH 002/121] refactor(web): move the official settings pages into companion packages Each official settings page now lives in a client package of its own: `ui-settings-shell`, `ui-settings-agent-loop`, `ui-settings-subagent` and `ui-settings-web-search` register into `plugins.item` while the Host serves their namespaces, through the new `settingsScope.whileServed` watch, each with its own dictionary and styles. The form model, the fields and the form move to `ui-primitives` as the shared settings kit, so a community package's browser half builds a page the same way. `ui-settings-plugins` keeps only the Built-in plugins section shell around the inventory tab. The companions hold Loader rows in `web-app` behind the section; the client catalog, config catalog and module graph regenerate; the settings-card cookbook walks through a companion package. --- ...onfiguration-on-the-plugins-page.i18n.yaml | 4 +- ...lugin-configuration-on-the-plugins-page.md | 4 +- ...in-configuration-on-the-plugins-page.zh.md | 4 +- ...ings-pages-as-companion-packages.i18n.yaml | 6 + ...17-settings-pages-as-companion-packages.md | 39 ++ ...settings-pages-as-companion-packages.zh.md | 39 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 + docs/config-catalog.zh.md | 4 + .../cookbook/adding-a-settings-card.i18n.yaml | 4 +- docs/cookbook/adding-a-settings-card.md | 73 +-- docs/cookbook/adding-a-settings-card.zh.md | 77 +-- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 8 + docs/module-graph.zh.md | 8 + packages/bundle/web-app/cordis.patch.yml | 15 + packages/bundle/web-app/package.json | 4 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 3 + packages/client/ui-primitives/README.zh.md | 3 + packages/client/ui-primitives/package.json | 1 + packages/client/ui-primitives/src/index.ts | 9 + .../settings-form/SettingsForm.module.css} | 2 +- .../src/settings-form/SettingsForm.tsx} | 49 +- .../src/settings-form}/fields.module.css | 2 +- .../src/settings-form}/fields.tsx | 21 +- .../src/settings-form/form-model.ts} | 122 ++-- .../tests/settings-fields.client.spec.tsx} | 30 +- .../tests/settings-form-model.client.spec.ts | 298 ++++++++++ .../tests/settings-form.client.spec.tsx | 82 +++ packages/client/ui-primitives/tsconfig.json | 5 + .../ui-settings-agent-loop/README.i18n.yaml | 6 + .../client/ui-settings-agent-loop/README.md | 78 +++ .../ui-settings-agent-loop/README.zh.md | 78 +++ .../ui-settings-agent-loop/package.json | 65 +++ .../src/client/AgentLoopCard.tsx | 27 +- .../src/client/agent-loop-card-controller.ts | 34 +- .../src/client/index.ts | 49 ++ .../src/client/locales.ts | 50 ++ .../src/css-modules.d.ts | 4 + .../ui-settings-agent-loop/src/index.ts | 10 + .../tests/apply.client.spec.ts | 95 +++ .../tests/card.client.spec.tsx | 66 +++ .../tests/controller.client.spec.ts | 41 ++ .../ui-settings-agent-loop/tsconfig.json | 42 ++ .../ui-settings-agent-loop/tsdown.config.ts | 3 + .../ui-settings-plugins/README.i18n.yaml | 4 +- packages/client/ui-settings-plugins/README.md | 57 +- .../client/ui-settings-plugins/README.zh.md | 65 +-- .../client/ui-settings-plugins/package.json | 10 +- .../src/client/bash-card-controller.ts | 64 -- .../ui-settings-plugins/src/client/index.ts | 131 +---- .../ui-settings-plugins/src/client/locales.ts | 141 +---- .../tests/apply.client.spec.ts | 210 +------ .../tests/section.client.spec.tsx | 551 +----------------- .../client/ui-settings-plugins/tsconfig.json | 12 - .../client/ui-settings-shell/README.i18n.yaml | 6 + packages/client/ui-settings-shell/README.md | 78 +++ .../client/ui-settings-shell/README.zh.md | 78 +++ .../client/ui-settings-shell/package.json | 65 +++ .../src/client/ShellCard.tsx} | 47 +- .../ui-settings-shell/src/client/index.ts | 50 ++ .../ui-settings-shell/src/client/locales.ts | 55 ++ .../src/client/shell-card-controller.ts | 66 +++ .../ui-settings-shell/src/css-modules.d.ts | 4 + .../client/ui-settings-shell/src/index.ts | 10 + .../tests/apply.client.spec.ts | 113 ++++ .../tests/card.client.spec.tsx | 125 ++++ .../tests/controller.client.spec.ts | 91 +++ .../client/ui-settings-shell/tsconfig.json | 42 ++ .../client/ui-settings-shell/tsdown.config.ts | 3 + .../ui-settings-subagent/README.i18n.yaml | 6 + .../client/ui-settings-subagent/README.md | 82 +++ .../client/ui-settings-subagent/README.zh.md | 82 +++ .../client/ui-settings-subagent/package.json | 65 +++ .../src/client/SubagentCard.module.css | 0 .../src/client/SubagentCard.tsx | 9 +- .../client/SubagentLimitsFields.module.css | 0 .../src/client/SubagentLimitsFields.tsx | 8 +- .../SubagentModelSelectionFields.module.css | 0 .../client/SubagentModelSelectionFields.tsx | 2 +- .../ui-settings-subagent/src/client/index.ts | 92 +++ .../src/client/locales.ts | 109 ++++ .../src/client/subagent-card-controller.ts | 4 +- .../client/subagent-limits-card-controller.ts | 24 +- ...ubagent-model-selection-card-controller.ts | 4 +- .../ui-settings-subagent/src/css-modules.d.ts | 4 + .../client/ui-settings-subagent/src/index.ts | 11 + .../tests/apply.client.spec.ts | 117 ++++ .../tests/card.client.spec.tsx | 292 ++++++++++ .../tests/controllers.client.spec.ts} | 525 ----------------- .../client/ui-settings-subagent/tsconfig.json | 42 ++ .../ui-settings-subagent/tsdown.config.ts | 3 + .../ui-settings-web-search/README.i18n.yaml | 6 + .../client/ui-settings-web-search/README.md | 79 +++ .../ui-settings-web-search/README.zh.md | 79 +++ .../ui-settings-web-search/package.json | 65 +++ .../src/client/WebSearchCard.tsx | 41 +- .../src/client/index.ts | 59 ++ .../src/client/locales.ts | 64 ++ .../src/client/web-search-card-controller.ts | 48 +- .../src/css-modules.d.ts | 4 + .../ui-settings-web-search/src/index.ts | 11 + .../tests/apply.client.spec.ts | 109 ++++ .../tests/card.client.spec.tsx | 101 ++++ .../tests/controller.client.spec.ts | 202 +++++++ .../ui-settings-web-search/tsconfig.json | 42 ++ .../ui-settings-web-search/tsdown.config.ts | 3 + .../ui-settings/src/client/settings-scope.ts | 31 + .../tests/while-served.client.spec.ts | 94 +++ pnpm-lock.yaml | 162 ++++- .../verify-package-readme-model-experience.ts | 4 + tsconfig.base.json | 4 + tsconfig.client.json | 4 + 114 files changed, 4340 insertions(+), 2011 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md rename packages/client/{ui-settings-plugins/src/client/PluginConfigForm.module.css => ui-primitives/src/settings-form/SettingsForm.module.css} (87%) rename packages/client/{ui-settings-plugins/src/client/PluginConfigForm.tsx => ui-primitives/src/settings-form/SettingsForm.tsx} (53%) rename packages/client/{ui-settings-plugins/src/client => ui-primitives/src/settings-form}/fields.module.css (96%) rename packages/client/{ui-settings-plugins/src/client => ui-primitives/src/settings-form}/fields.tsx (86%) rename packages/client/{ui-settings-plugins/src/client/card-form.ts => ui-primitives/src/settings-form/form-model.ts} (73%) rename packages/client/{ui-settings-plugins/tests/fields.client.spec.tsx => ui-primitives/tests/settings-fields.client.spec.tsx} (79%) create mode 100644 packages/client/ui-primitives/tests/settings-form-model.client.spec.ts create mode 100644 packages/client/ui-primitives/tests/settings-form.client.spec.tsx create mode 100644 packages/client/ui-settings-agent-loop/README.i18n.yaml create mode 100644 packages/client/ui-settings-agent-loop/README.md create mode 100644 packages/client/ui-settings-agent-loop/README.zh.md create mode 100644 packages/client/ui-settings-agent-loop/package.json rename packages/client/{ui-settings-plugins => ui-settings-agent-loop}/src/client/AgentLoopCard.tsx (63%) rename packages/client/{ui-settings-plugins => ui-settings-agent-loop}/src/client/agent-loop-card-controller.ts (52%) create mode 100644 packages/client/ui-settings-agent-loop/src/client/index.ts create mode 100644 packages/client/ui-settings-agent-loop/src/client/locales.ts create mode 100644 packages/client/ui-settings-agent-loop/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-agent-loop/src/index.ts create mode 100644 packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx create mode 100644 packages/client/ui-settings-agent-loop/tests/controller.client.spec.ts create mode 100644 packages/client/ui-settings-agent-loop/tsconfig.json create mode 100644 packages/client/ui-settings-agent-loop/tsdown.config.ts delete mode 100644 packages/client/ui-settings-plugins/src/client/bash-card-controller.ts create mode 100644 packages/client/ui-settings-shell/README.i18n.yaml create mode 100644 packages/client/ui-settings-shell/README.md create mode 100644 packages/client/ui-settings-shell/README.zh.md create mode 100644 packages/client/ui-settings-shell/package.json rename packages/client/{ui-settings-plugins/src/client/BashCard.tsx => ui-settings-shell/src/client/ShellCard.tsx} (51%) create mode 100644 packages/client/ui-settings-shell/src/client/index.ts create mode 100644 packages/client/ui-settings-shell/src/client/locales.ts create mode 100644 packages/client/ui-settings-shell/src/client/shell-card-controller.ts create mode 100644 packages/client/ui-settings-shell/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-shell/src/index.ts create mode 100644 packages/client/ui-settings-shell/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-shell/tests/card.client.spec.tsx create mode 100644 packages/client/ui-settings-shell/tests/controller.client.spec.ts create mode 100644 packages/client/ui-settings-shell/tsconfig.json create mode 100644 packages/client/ui-settings-shell/tsdown.config.ts create mode 100644 packages/client/ui-settings-subagent/README.i18n.yaml create mode 100644 packages/client/ui-settings-subagent/README.md create mode 100644 packages/client/ui-settings-subagent/README.zh.md create mode 100644 packages/client/ui-settings-subagent/package.json rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentCard.module.css (100%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentCard.tsx (90%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentLimitsFields.module.css (100%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentLimitsFields.tsx (87%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentModelSelectionFields.module.css (100%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentModelSelectionFields.tsx (99%) create mode 100644 packages/client/ui-settings-subagent/src/client/index.ts create mode 100644 packages/client/ui-settings-subagent/src/client/locales.ts rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/subagent-card-controller.ts (96%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/subagent-limits-card-controller.ts (67%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/subagent-model-selection-card-controller.ts (98%) create mode 100644 packages/client/ui-settings-subagent/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-subagent/src/index.ts create mode 100644 packages/client/ui-settings-subagent/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-subagent/tests/card.client.spec.tsx rename packages/client/{ui-settings-plugins/tests/stores.client.spec.ts => ui-settings-subagent/tests/controllers.client.spec.ts} (57%) create mode 100644 packages/client/ui-settings-subagent/tsconfig.json create mode 100644 packages/client/ui-settings-subagent/tsdown.config.ts create mode 100644 packages/client/ui-settings-web-search/README.i18n.yaml create mode 100644 packages/client/ui-settings-web-search/README.md create mode 100644 packages/client/ui-settings-web-search/README.zh.md create mode 100644 packages/client/ui-settings-web-search/package.json rename packages/client/{ui-settings-plugins => ui-settings-web-search}/src/client/WebSearchCard.tsx (69%) create mode 100644 packages/client/ui-settings-web-search/src/client/index.ts create mode 100644 packages/client/ui-settings-web-search/src/client/locales.ts rename packages/client/{ui-settings-plugins => ui-settings-web-search}/src/client/web-search-card-controller.ts (83%) create mode 100644 packages/client/ui-settings-web-search/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-web-search/src/index.ts create mode 100644 packages/client/ui-settings-web-search/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-web-search/tests/card.client.spec.tsx create mode 100644 packages/client/ui-settings-web-search/tests/controller.client.spec.ts create mode 100644 packages/client/ui-settings-web-search/tsconfig.json create mode 100644 packages/client/ui-settings-web-search/tsdown.config.ts create mode 100644 packages/client/ui-settings/tests/while-served.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml index d1c3e82cbb..737867c4a5 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md -2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4 -2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc +2026-09-16-plugin-configuration-on-the-plugins-page.md: d9736c6289ef2605053ce7c6ce28368998b73da0 +2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: a3782c5629cf282cf9b1e1df78e5defc8fd4a997 diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md index fb330bee44..d9736c6289 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md @@ -16,14 +16,14 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta **Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence. -**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired. +**The four host-plane pages register while the Host serves their namespaces.** Each page's package registers it through `ctx.slots.inject` when the shared settings mirror shows the namespace and disposes it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it; since [settings pages as companion packages](2026-09-17-settings-pages-as-companion-packages.md) that is one companion package per page, and `ui-settings-plugins` keeps the Settings section as the **Built-in plugins** shell around the inventory tab. The `settings.plugin.item` slot is retired. **The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example. ## Consequences - A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own. -- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page. +- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it owns while the pages live on the Plugins page. - Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md index 65c1f5a8bf..a3782c5629 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md @@ -16,14 +16,14 @@ **只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。 -**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。 +**四个宿主平面页面在 Host 服务其命名空间期间注册。** 每个页面所在的包在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹;自[设置页作为伴生包](2026-09-17-settings-pages-as-companion-packages.zh.md)起,每个页面各是一个伴生包,`ui-settings-plugins` 把设置分区保留为清单标签页外面的**内置插件**外壳。`settings.plugin.item` slot 退役。 **官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。 ## 后果 - 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。 -- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。 +- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,而页面住在插件页上。 - 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml new file mode 100644 index 0000000000..0dd8337ca2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md +2026-09-17-settings-pages-as-companion-packages.md: dc604c010787ee35522b351b643fb2691540b4f9 +2026-09-17-settings-pages-as-companion-packages.zh.md: 066b17c9760f381089a6a48b1ec6606e600531d3 diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md new file mode 100644 index 0000000000..dc604c0107 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md @@ -0,0 +1,39 @@ +# Agent Note: Settings pages as companion packages + +Status: implemented + +English | [中文](2026-09-17-settings-pages-as-companion-packages.zh.md) + +## Problem + +The four official settings pages — shell, agent loop, Subagent, web search — lived in one client package, `ui-settings-plugins`, beside the Built-in plugins section. Every new built-in page grew that package, and a community bundle that wanted a settings page had no worked example of a page living with the plugin that owns the namespace. Moving each page into the Host package that registers its namespace was considered and does not fit: the shell namespace is registered by the executor families under the sandbox row, the loop package would gain a browser build for one number field, and the Subagent namespaces are registered from a subpath row, which carries no browser half. + +## Decision + +**A community plugin's settings page lives in its own package's browser half.** It registers into `plugins.bundle.config` or `plugins.row.config`, owns its copy and styles, and reads and writes through `ctx.settingsScope`. Its i18n is the client locale service, as the shipped community bundles already do. + +**A built-in namespace's page lives in a companion client package.** `ui-settings-shell`, `ui-settings-agent-loop`, `ui-settings-subagent`, and `ui-settings-web-search` are client-only packages with an empty Host `apply`, one bare-name row each in the web composition's roster, registering into `plugins.item` while the Host serves their namespace. Host packages stay free of browser code; the namespace they edit is spelled as a literal, never imported from the owner. + +**The form machinery moves to `ui-primitives`.** `SettingsFormModel` with `settingsNumberField` and `settingsTextField`, `SettingsValueField` and `SettingsSecretField`, and the `SettingsForm` frame are the kit every page renders with; the frame takes its copy as `labels` and the model takes a structural scope, so the baseline package depends on neither a plugin nor its dictionary. + +**`settingsScope.whileServed(namespaces, register)` is the registration rule.** A page exists exactly while the Host serves one of its namespaces: the service watches the shared describe mirror, runs the registration when a namespace appears, and disposes it when none is served, so four packages share one rule instead of four copies. + +**`ui-settings-plugins` keeps the Built-in plugins section only:** the navigation entry and the tab row feature-owned tabs register into. + +## Alternatives considered + +**A schema-driven generic form.** Rejected: a generic renderer covers scalar fields and stops at the first dynamic control — the Subagent page's live model catalogue, its cross-field rule, its one-mutation save — and every exception would become a role the schema is not for. Every page is code; a community plugin that ships no page has none. + +**Dual-face Host packages for the built-in pages.** Rejected for the reasons above; the companion keeps the option open for a package that can carry a half. + +**Copy for the form frame in one shared dictionary.** Rejected: it would couple every page to another plugin's dictionary keys; each page's dictionary carries the few frame strings. + +## Consequences + +- The Plugins page's Official group lists the same four pages in the same order, from four packages instead of one; their labels and forms are unchanged, so the page goldens hold. +- The cookbook [adding a settings page](../../../../docs/cookbook/adding-a-settings-card.md) shows the community path and names the companion template. +- The client module system's rule stands: a browser half rides the Loader row whose specifier is the bare package name, so a bundle split into subpath rows keeps every page it registers on its root row. + +## Testing + +Each companion's `tests/apply.client.spec.ts` registers its page while the Host serves the namespace, withdraws it when the Host stops, and collapses on teardown; its card and controller specs carry the page's behavior from the former package. `packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`, `packages/client/ui-primitives/tests/settings-form.client.spec.tsx`, and `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` pin the kit; `packages/client/ui-settings/tests/while-served.client.spec.ts` pins the registration rule. `apps/web/tests/plugin-config.e2e.ts` exercises the shell and Subagent pages over the real wire. diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md new file mode 100644 index 0000000000..066b17c976 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md @@ -0,0 +1,39 @@ +# Agent Note:设置页作为伴生包 + +状态:已实现 + +[English](2026-09-17-settings-pages-as-companion-packages.md) | 中文 + +## 问题 + +四个官方设置页——终端、Agent 循环、Subagent、网页搜索——都放在一个客户端包 `ui-settings-plugins` 里,与「内置插件」分区挨着。每新增一个内置页面都让这个包再长一截,而想要设置页的社区组合包也找不到"页面与拥有命名空间的插件放在一起"的现成样板。曾考虑把每个页面搬进注册其命名空间的宿主包,但行不通:shell 命名空间由执行器家族在 sandbox 行之下注册,循环包为了一个数字字段要长出浏览器构建,Subagent 的命名空间从子路径行注册,而子路径行不携带浏览器半侧。 + +## 决策 + +**社区插件的设置页放在自己包的浏览器半侧。** 它注册进 `plugins.bundle.config` 或 `plugins.row.config`,自己拥有文案和样式,通过 `ctx.settingsScope` 读写。它的 i18n 走客户端 locale 服务,已发布的社区组合包本来就是这么做的。 + +**内置命名空间的页面放在伴生客户端包里。** `ui-settings-shell`、`ui-settings-agent-loop`、`ui-settings-subagent`、`ui-settings-web-search` 是纯客户端包,宿主 `apply` 为空,各在 Web 组合花名册里占一条裸名行,在 Host 服务其命名空间期间注册进 `plugins.item`。宿主包不含浏览器代码;它们编辑的命名空间以字面量拼写,从不从 owner 导入。 + +**表单机制搬进 `ui-primitives`。** `SettingsFormModel` 及 `settingsNumberField`、`settingsTextField`,`SettingsValueField` 与 `SettingsSecretField`,以及 `SettingsForm` 框架,是每个页面共同渲染用的套件;框架以 `labels` 接收文案,模型接受结构化的 scope,因此基线包既不依赖任何插件也不依赖其字典。 + +**`settingsScope.whileServed(namespaces, register)` 是注册规则。** 页面恰好在 Host 服务其某个命名空间期间存在:服务监视共享的 describe 镜像,命名空间出现时执行注册,一个都不再服务时注销,四个包共用一条规则而不是四份副本。 + +**`ui-settings-plugins` 只保留「内置插件」分区:** 导航项和供功能插件注册标签页的标签行。 + +## 考虑过的替代方案 + +**由 schema 驱动的通用表单。** 否决:通用渲染器只覆盖标量字段,碰到第一个动态控件就止步——Subagent 页的实时模型目录、跨字段规则、一次 mutate 提交——而每个例外都会变成一个 schema 本不该承担的 role。每个页面都是代码;没有发页面的社区插件就没有页面。 + +**内置页面做成双面宿主包。** 因上述原因否决;伴生包给能带半侧的包留了这个选项。 + +**表单框架的文案放在一本共享字典里。** 否决:那会让每个页面耦合到另一个插件的字典键;每个页面的字典自带那几条框架文案。 + +## 后果 + +- 插件页官方分组列出同样四个页面、同样顺序,来自四个包而不是一个;标签与表单未变,页面 golden 保持不变。 +- 手册[添加设置页](../../../../docs/cookbook/adding-a-settings-card.zh.md)展示社区路径并点名伴生包模板。 +- 客户端模块系统的规则不变:浏览器半侧挂在说明符为裸包名的 Loader 行上,因此拆成子路径行的组合包,其注册的所有页面都留在根行上。 + +## 测试 + +每个伴生包的 `tests/apply.client.spec.ts` 在 Host 服务命名空间时注册页面、停止服务时注销、teardown 时收拢;其 card 与 controller 用例延续原包里的页面行为。`packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`、`packages/client/ui-primitives/tests/settings-form.client.spec.tsx` 与 `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` 钉住套件;`packages/client/ui-settings/tests/while-served.client.spec.ts` 钉住注册规则。`apps/web/tests/plugin-config.e2e.ts` 走真实链路验证终端页与 Subagent 页。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7da9acd525..b01bb4e0dd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 631ce6a34b120f44534a7935ff976662287fa879 -config-catalog.zh.md: 8d72786a79ceff85e35aff72fa6622af7373a3b4 +config-catalog.md: 6d96a3c041e5778c1fc144cf8c5008d7ceaf7cc1 +config-catalog.zh.md: ed4b009047f615217d78eb6e7f2050bbbfb8de8c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 631ce6a34b..6d96a3c041 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3806,11 +3806,15 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-schedule` ([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts)) - `@deepseek-ai/dsh-client-ui-session` ([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-agent-loop` ([`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-shell` ([`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-subagent` ([`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-web-search` ([`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8d72786a79..ed4b009047 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3808,11 +3808,15 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-schedule`([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts)) - `@deepseek-ai/dsh-client-ui-session`([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-agent-loop`([`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-shell`([`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-subagent`([`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-web-search`([`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/cookbook/adding-a-settings-card.i18n.yaml b/docs/cookbook/adding-a-settings-card.i18n.yaml index 52f7dc2c1c..d04ea95824 100644 --- a/docs/cookbook/adding-a-settings-card.i18n.yaml +++ b/docs/cookbook/adding-a-settings-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-settings-card.md -adding-a-settings-card.md: d1104299d319203789c9cd153f36b3f5e33edd10 -adding-a-settings-card.zh.md: 3ef245d76331015f4d69db52bfbd176dff3e4bcf +adding-a-settings-card.md: cc8daa90bcc3a757cd41dbcaecb0eab662d1dba8 +adding-a-settings-card.zh.md: a0fce4075b9e29630c772bc52e07cd00d89d5bab diff --git a/docs/cookbook/adding-a-settings-card.md b/docs/cookbook/adding-a-settings-card.md index d1104299d3..cc8daa90bc 100644 --- a/docs/cookbook/adding-a-settings-card.md +++ b/docs/cookbook/adding-a-settings-card.md @@ -1,10 +1,10 @@ -# Cookbook: adding a settings card +# Cookbook: adding a settings page English | [中文](adding-a-settings-card.zh.md) -How a plugin puts its own configuration on the web settings page. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the **Plugins** section keys its cards on the namespace they edit, so a plugin that registers both halves is paired up automatically. +How a plugin puts its own configuration on the web client's Plugins page, and how it contributes to another plugin's page there. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the Plugins page declares the slots a page or a contribution registers into. -The two halves live in one package — the Host half under `src/`, the browser half under `src/client/`, exported as `./client` and declared with `dsh.client`. [`packages/client/ui-theme`](../../packages/client/ui-theme) is a worked example of that packaging; the cards this section ships live in [`packages/client/ui-settings-plugins`](../../packages/client/ui-settings-plugins). +The two halves of a community bundle live in one package — the Host half under `src/`, the browser half under `src/client/`, exported as `./client` and declared with `dsh.client`. A built-in plugin whose package cannot carry a browser half ships its page as a companion client package instead; [`packages/client/ui-settings-shell`](../../packages/client/ui-settings-shell) is the template. ## 1. Register the namespace (Host half) @@ -43,43 +43,58 @@ export function apply(ctx: Context, config: Config) { } ``` -`role('secret')` on a field keeps its value off every response; the card writes such a field into an `update`/`mutate` payload, or addresses a credential reference through the `credentials` domain instead. `applies: 'restart'` tells a configuration surface the owner acts on a change only at the next start. +`role('secret')` on a field keeps its value off every response; the page writes such a field through the `credentials` domain instead. `applies: 'restart'` tells a configuration surface the owner acts on a change only at the next start. -## 2. Register the card (browser half) +## 2. Register the page (browser half) -The card registers into `settings.plugin.item` under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through `ctx.settingsScope`, which fences each write with the revision it read: +The page registers into one of the Plugins page's configuration slots and owns everything inside it — controls, copy, and styles. A bundle's own configuration goes into `plugins.bundle.config`, keyed by the package name; the configuration of one row the bundle declares goes into `plugins.row.config`, keyed by `#`, which gives that row a configure control opening the row's page. The page asks every entry for two views: `summary` for the one-liner under the title, `page` for the form. + +The form reads and writes through `ctx.settingsScope`, which fences each write with the revision it read. The `ui-primitives` kit carries the staged-edit model and the controls, so a page declares its fields and renders them: ```ts ignore-check import type { Context as ClientContext } from '@deepseek-ai/cordis' -// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes -// through cordis services; a value import fails the client bundle-purity gate. -import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +// Type-only: the Plugins page's slot declarations and the settings scope. +// Cross-plugin collaboration goes through cordis services; a value import +// fails the client bundle-purity gate. +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsFormModel, settingsNumberField, settingsTextField } from '@deepseek-ai/dsh-client-ui-primitives' -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'settingsScope'] export function apply(ctx: ClientContext): void { - const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' })) - ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ - name: 'settings.plugin.item', - key: 'my-plugin', - locale: 'settings.myPlugin', - inject: () => card.inject(), - }, MyPluginCard), + ctx.effect(() => ctx.locale.register('myPlugin', { zh, en })) + const form = new SettingsFormModel( + ctx.settingsScope.bind({ namespace: 'my-plugin' }), + [settingsTextField('endpoint'), settingsNumberField('retries')], ) + const store = form.bind(() => ({ ...form.shell(), endpoint: form.field('endpoint'), retries: form.field('retries') })) + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', + key: '@acme/dsh-my-plugin', + locale: 'myPlugin', + inject: () => ({ hooks: { myPluginForm: store }, ...form.actions() }), + }, MyPluginPage)) } ``` -The scope snapshot carries what a form needs: the resolved `value`, the composition `base`, and the raw `user` layer, whose key **presence** — not its value — is what marks a field overridden. `scope.set(field, value)` stores one field and `scope.unset(field)` clears it back to the composition layer. +`MyPluginPage` renders `t('summary')` for `view === 'summary'` and, for `page`, a `SettingsForm` around `SettingsValueField` controls, passing the frame its copy as `labels`. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Only a save writes, and leaving the page drops every draft. -## 3. What the tab does with it +## 3. Contribute to another plugin's page -The **Plugin configuration** tab reads which namespaces the Host serves and dispatches one slot key per namespace. A card is rendered when the Host serves its key and skipped when it does not, so a deployment that never composed the Host half shows no trace of the card. A served namespace no card claims renders nothing — that is how the namespaces owned by other pages (`ui-theme`, `permission`, `llm-*`) stay off this tab. +A plugin with something to say about a bundle, a row, or an official plugin it does not own registers into `plugins.detail.actions` (a control at the head of the page), `plugins.detail.badge` (a tag beside the title), or `plugins.detail.section` (a section under the page's own content). Every entry is rendered with the page's `subject` — `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }` — and returns null for a subject it has nothing for: -Cards appear in the order they registered into the slot; a keyed entry declares no `order` of its own. +```ts ignore-check +ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ + name: 'plugins.detail.badge', + id: 'acme-update', + locale: 'acmeUpdate', +}, ({ t, subject }) => subject.kind === 'bundle' && hasUpdate(subject.pkg) ? {t('update')} : null)) +``` -## Packaging +## 4. Where the browser half rides -The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export. So the plugin appears on the page as soon as a `cordis.yml` mounts it — no rebuild of the web application. +The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export — but it attaches a package's half to the Loader row whose specifier is the bare package name. A row mounted from a subpath export never carries a half, so a bundle that splits one package into several rows keeps its half on the root row, and every page it registers goes away when that row is switched off. A sub-plugin whose page must outlive the other rows ships as its own package. ```jsonc { @@ -87,16 +102,8 @@ The browser half is served to the page by the [client module system](../../packa ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } }, - "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } } + "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings"] } } } ``` -The bundle must be the loader's lazy-CJS factory artifact. Inside this repository `tsdown.config.ts` is three lines over the shared preset: - -```ts ignore-check -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js']) -``` - -No published preset exposes this package, so a package outside this repository has to reproduce the same output format itself. The bundle-purity gate also rejects value imports across plugins, so a card cannot import this section's card chrome or its staged-form model — it renders its own, and owns its own staging and revision fencing. Both limits are recorded under [the section's known limitations](../../packages/client/ui-settings-plugins/README.md#known-limitations-and-deferred-work). +A companion package for a built-in namespace is the same browser half in a client-only package with an empty Host `apply`, listed in the web composition's plugin roster ([`packages/bundle/web-app/cordis.patch.yml`](../../packages/bundle/web-app/cordis.patch.yml)) and registering into `plugins.item` through `ctx.settingsScope.whileServed`, so the page exists exactly while the Host serves the namespace. diff --git a/docs/cookbook/adding-a-settings-card.zh.md b/docs/cookbook/adding-a-settings-card.zh.md index 3ef245d763..a0fce4075b 100644 --- a/docs/cookbook/adding-a-settings-card.zh.md +++ b/docs/cookbook/adding-a-settings-card.zh.md @@ -1,14 +1,14 @@ -# Cookbook: 新增设置卡片 +# 手册:添加设置页 [English](adding-a-settings-card.md) | 中文 -插件如何把自己的配置放上 Web 设置页。这条路径上没有任何一步需要改动本仓库:Host 服务每一个已注册的 settings 命名空间,而**插件配置**分区以卡片所编辑的命名空间为键,因此同时注册了两个半侧的插件会被自动配对。 +插件如何把自己的配置放到 Web 客户端的插件页上,以及如何向那里的其他插件页面贡献内容。这条路径不需要改动本仓库:Host 服务每一个已注册的设置命名空间,插件页声明了页面或贡献要注册进去的 slot。 -两个半侧住在同一个包里——Host 半侧在 `src/`,浏览器半侧在 `src/client/`,以 `./client` 导出并用 `dsh.client` 声明。[`packages/client/ui-theme`](../../packages/client/ui-theme) 是这种打包方式的现成例子;本分区自带的卡片在 [`packages/client/ui-settings-plugins`](../../packages/client/ui-settings-plugins)。 +社区组合包的两个半侧放在一个包里——宿主半侧在 `src/`,浏览器半侧在 `src/client/`,导出为 `./client` 并用 `dsh.client` 声明。包本身带不了浏览器半侧的内置插件,则把页面做成一个伴生客户端包;[`packages/client/ui-settings-shell`](../../packages/client/ui-settings-shell) 是模板。 -## 1. 注册命名空间(Host 半侧) +## 1. 注册命名空间(宿主半侧) -命名空间就是配对用的键,所以只挑一次,并在两个半侧都写出它。已经有 `cordis.yml` entry 的消费方应通过 `ctx.settings.installSection()` 注册——它把 entry 层叠在用户文档之下,并在没有挂载 settings provider 时照常工作: +命名空间是连接键,选定一次,两个半侧拼写一致。已有 `cordis.yml` 条目的消费者应通过 `ctx.settings.installSection()` 注册,它把条目铺在用户文档之下,并在没有挂载设置提供方时照常工作: ```ts import type { Context } from '@deepseek-ai/cordis' @@ -43,43 +43,58 @@ export function apply(ctx: Context, config: Config) { } ``` -字段上的 `role('secret')` 让它的值不出现在任何响应里;卡片把这类字段写进 `update`/`mutate` 载荷,或改为经 `credentials` 领域寻址一个凭据引用。`applies: 'restart'` 告诉配置表层:拥有方要到下次启动才会对变更生效。 +字段上的 `role('secret')` 让它的值不出现在任何响应里;页面改经 `credentials` 域写入这类字段。`applies: 'restart'` 告诉配置界面:所有者要到下次启动才响应更改。 -## 2. 注册卡片(浏览器半侧) +## 2. 注册页面(浏览器半侧) -卡片以自己的命名空间为键注册进 `settings.plugin.item`,并拥有其中的一切——外观、控件与文案。它通过 `ctx.settingsScope` 读写,后者用读取时的 revision 为每次写入设栅: +页面注册进插件页的某个配置 slot,并拥有其中的一切——控件、文案和样式。组合包自己的配置进 `plugins.bundle.config`,以包名为键;组合包声明的某一行的配置进 `plugins.row.config`,以 `<包名>#<行 id>` 为键,这会给该行一个打开行页的配置控件。页面向每个条目索取两种视图:`summary` 是标题下的一句话,`page` 是表单。 + +表单通过 `ctx.settingsScope` 读写,每次写入都以读取时的修订号作栅栏。`ui-primitives` 套件提供暂存编辑模型和控件,页面只需声明字段并渲染: ```ts ignore-check import type { Context as ClientContext } from '@deepseek-ai/cordis' -// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes -// through cordis services; a value import fails the client bundle-purity gate. -import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +// Type-only: the Plugins page's slot declarations and the settings scope. +// Cross-plugin collaboration goes through cordis services; a value import +// fails the client bundle-purity gate. +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsFormModel, settingsNumberField, settingsTextField } from '@deepseek-ai/dsh-client-ui-primitives' -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'settingsScope'] export function apply(ctx: ClientContext): void { - const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' })) - ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ - name: 'settings.plugin.item', - key: 'my-plugin', - locale: 'settings.myPlugin', - inject: () => card.inject(), - }, MyPluginCard), + ctx.effect(() => ctx.locale.register('myPlugin', { zh, en })) + const form = new SettingsFormModel( + ctx.settingsScope.bind({ namespace: 'my-plugin' }), + [settingsTextField('endpoint'), settingsNumberField('retries')], ) + const store = form.bind(() => ({ ...form.shell(), endpoint: form.field('endpoint'), retries: form.field('retries') })) + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', + key: '@acme/dsh-my-plugin', + locale: 'myPlugin', + inject: () => ({ hooks: { myPluginForm: store }, ...form.actions() }), + }, MyPluginPage)) } ``` -scope 快照携带表单所需的一切:解析后的 `value`、组装层 `base`,以及原始的 `user` 层——字段是否被覆盖,取决于它在 `user` 层中是否**出现**,而非它的值。`scope.set(field, value)` 存一个字段,`scope.unset(field)` 把它清回组装层。 +`MyPluginPage` 在 `view === 'summary'` 时渲染 `t('summary')`,在 `page` 时渲染一个包着 `SettingsValueField` 控件的 `SettingsForm`,并以 `labels` 把文案交给框架。字段是否被覆盖看它在原始用户层里是否存在,而不看它的值;重置会清除该字段,让它重新继承组合层。只有保存才写入,离开页面即丢弃所有草稿。 -## 3. 标签页拿它做什么 +## 3. 向其他插件的页面贡献内容 -**插件配置**标签页读取 Host 服务了哪些命名空间,并为每个命名空间派发一个 slot 键。当 Host 服务了某卡片的键时它被渲染,否则被跳过,因此从未组装过 Host 半侧的部署不会留下这张卡片的任何痕迹。被服务却无人认领的命名空间什么都不渲染——归其他页面所有的那些命名空间(`ui-theme`、`permission`、`llm-*`)正是这样留在本标签页之外的。 +对某个不属于自己的组合包、行或官方插件有话要说的插件,注册进 `plugins.detail.actions`(页头的控件)、`plugins.detail.badge`(标题旁的标签)或 `plugins.detail.section`(页面自身内容之下的区块)。每个条目都以页面的 `subject` 渲染——`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`——对无话可说的 subject 返回 null: -卡片按其注册进该 slot 的顺序出现;keyed entry 不声明自己的 `order`。 +```ts ignore-check +ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ + name: 'plugins.detail.badge', + id: 'acme-update', + locale: 'acmeUpdate', +}, ({ t, subject }) => subject.kind === 'bundle' && hasUpdate(subject.pkg) ? {t('update')} : null)) +``` -## 打包 +## 4. 浏览器半侧挂在哪里 -浏览器半侧由[客户端模块系统](../../packages/client/modules)提供给页面:它扫描已启用的 Loader entries 中声明了 `dsh.client` 的包,并提供每个包构建出的 `./client` 导出。因此只要 `cordis.yml` 挂载了该插件,它就会出现在页面上——无需重新构建 Web 应用。 +浏览器半侧由[客户端模块系统](../../packages/client/modules)送到页面:它扫描已启用的 Loader 条目,找出声明了 `dsh.client` 的包,送出每个包构建好的 `./client` 导出——但它只把一个包的半侧挂在说明符恰为裸包名的那一行上。从子路径导出挂载的行永远不带半侧,因此把一个包拆成多行的组合包,其半侧留在根行上,它注册的每个页面都随根行关闭而消失。需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 ```jsonc { @@ -87,16 +102,8 @@ scope 快照携带表单所需的一切:解析后的 `value`、组装层 `base ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } }, - "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } } + "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings"] } } } ``` -bundle 必须是 loader 的 lazy-CJS factory 产物。在本仓库内,`tsdown.config.ts` 就是基于共享预设的三行: - -```ts ignore-check -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js']) -``` - -没有已发布的预设暴露该包,因此本仓库之外的包得自行复刻同样的输出格式。bundle 纯净度门禁同时拒绝跨插件的值导入,所以卡片无法导入本分区的卡片外观或其暂存表单模型——它渲染自己的那一份,并自行拥有暂存与 revision 设栅。这两条限制都记在[本分区的已知限制](../../packages/client/ui-settings-plugins/README.zh.md#known-limitations-and-deferred-work)里。 +内置命名空间的伴生包就是同样的浏览器半侧放在一个纯客户端包里,宿主 `apply` 为空,列入 Web 组合的插件花名册([`packages/bundle/web-app/cordis.patch.yml`](../../packages/bundle/web-app/cordis.patch.yml)),并通过 `ctx.settingsScope.whileServed` 注册进 `plugins.item`,页面因此恰好在 Host 服务该命名空间期间存在。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a71977cacf..30d8e47841 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 8ec3673173c299fdd69134af1002ba157d6db870 -module-graph.zh.md: 1dd43514acff155c92c139dca7ad519e137e2a03 +module-graph.md: 227db2a1e1cbd312bfada48704c7b23d7cd0809b +module-graph.zh.md: 40a90450ce04fd0a759b8939bdb0d02cb5d20be9 diff --git a/docs/module-graph.md b/docs/module-graph.md index 8ec3673173..227db2a1e1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -176,11 +176,15 @@ flowchart TD pkg_client_ui_schedule["client-ui-schedule"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] + pkg_client_ui_settings_agent_loop["client-ui-settings-agent-loop"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] + pkg_client_ui_settings_shell["client-ui-settings-shell"] + pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"] + pkg_client_ui_settings_web_search["client-ui-settings-web-search"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1358,11 +1362,15 @@ flowchart TD | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | +| [`client-ui-settings-agent-loop`](../packages/client/ui-settings-agent-loop) | `client` | — | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | +| [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | +| [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — | +| [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1dd43514ac..40a90450ce 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -178,11 +178,15 @@ flowchart TD pkg_client_ui_schedule["client-ui-schedule"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] + pkg_client_ui_settings_agent_loop["client-ui-settings-agent-loop"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] + pkg_client_ui_settings_shell["client-ui-settings-shell"] + pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"] + pkg_client_ui_settings_web_search["client-ui-settings-web-search"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1360,11 +1364,15 @@ flowchart TD | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | +| [`client-ui-settings-agent-loop`](../packages/client/ui-settings-agent-loop) | `client` | — | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | +| [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | +| [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — | +| [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 9fd0effbc5..8ade59ad22 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -364,6 +364,21 @@ - id: ui-settings-plugins name: '@deepseek-ai/dsh-client-ui-settings-plugins' + # The official settings pages: one companion package per host-plane + # namespace, each registering its page into the Plugins page while the + # Host serves the namespace. + - id: ui-settings-shell + name: '@deepseek-ai/dsh-client-ui-settings-shell' + + - id: ui-settings-agent-loop + name: '@deepseek-ai/dsh-client-ui-settings-agent-loop' + + - id: ui-settings-subagent + name: '@deepseek-ai/dsh-client-ui-settings-subagent' + + - id: ui-settings-web-search + name: '@deepseek-ai/dsh-client-ui-settings-web-search' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index aa70ffb352..782f91e2bc 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -83,6 +83,10 @@ "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-shell": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-agent-loop": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-subagent": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-web-search": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 706236dc2a..158c3790f9 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: d466de8e4a26a6476e373e19c45c72ae22197f1b -README.zh.md: eb6f4ce836eb8a219aad38f0941e666f3391ebc6 +README.md: 97be7103ad1aaddb7ed6647c7393d9d521d27a7c +README.zh.md: a0e6dc87e419bd12e775c28de92048df3b8ac01b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d466de8e4a..97be7103ad 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -52,6 +52,8 @@ Check this table before writing a control in a feature package. A plugin cannot | `Tooltip` | Hover text on a cloned anchor, placed right, bottom, or top. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | +| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank and reports only whether a value is configured. | +| `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | @@ -105,6 +107,7 @@ The package enforces one separation: presentational React atoms with zero Cordis | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards | | [`src/icons/`](src/icons/) | `ic_ds_*` glyph components and brand marks | | [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | Floating-panel and overlay geometry hooks | +| [`src/settings-form/`](src/settings-form/) | The settings page kit: the staged form model over a settings scope, the value and secret fields, and the form frame | ### Streaming markdown diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index eb6f4ce836..a0e6dc87e4 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -52,6 +52,8 @@ kind: "package-library" | `Tooltip` | 克隆锚点上的悬停文本,可置于右、下、上三个方向。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | +| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 | +| `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | @@ -105,6 +107,7 @@ kind: "package-library" | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 | | [`src/icons/`](src/icons/) | `ic_ds_*` 字形组件与品牌标记 | | [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | 浮动面板与浮层几何钩子 | +| [`src/settings-form/`](src/settings-form/) | 设置页套件:基于设置 scope 的暂存表单模型、值字段与密文字段、表单框架 | ### 流式 Markdown diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index c10bf53c7d..6dad7d305d 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -23,6 +23,7 @@ }, "license": "MIT", "devDependencies": { + "@deepseek-ai/dsh-client-store": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index ff8bd4213a..4642ac6909 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -44,6 +44,15 @@ export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' export { writeClipboard } from './clipboard.ts' +export { SettingsForm } from './settings-form/SettingsForm.tsx' +export type { SettingsFormLabels, SettingsFormProps } from './settings-form/SettingsForm.tsx' +export { SettingsSecretField, SettingsValueField } from './settings-form/fields.tsx' +export type { SettingsFieldProps } from './settings-form/fields.tsx' +export { SettingsFormModel, settingsNumberField, settingsTextField } from './settings-form/form-model.ts' +export type { + SettingsFieldSpec, SettingsFieldState, SettingsFieldWrite, SettingsFormActions, SettingsFormScope, + SettingsFormScopeSnapshot, SettingsFormShell, SettingsSecretSpec, +} from './settings-form/form-model.ts' export { relativeTime } from './relative-time.ts' export { rankByName } from './rank-by-name.ts' export { isDarwinDesktop } from './darwin-desktop.ts' diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css b/packages/client/ui-primitives/src/settings-form/SettingsForm.module.css similarity index 87% rename from packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css rename to packages/client/ui-primitives/src/settings-form/SettingsForm.module.css index cf1aade88f..01bca0b749 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css +++ b/packages/client/ui-primitives/src/settings-form/SettingsForm.module.css @@ -1,4 +1,4 @@ -/* Plugin configuration form: the controls the page shows under a plugin's title, and the save that writes them. */ +/* Settings form: the controls a page shows under a plugin's title, and the save that writes them. */ .form { display: flex; diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx b/packages/client/ui-primitives/src/settings-form/SettingsForm.tsx similarity index 53% rename from packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx rename to packages/client/ui-primitives/src/settings-form/SettingsForm.tsx index 0c3a4bf202..38cc6097f4 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx +++ b/packages/client/ui-primitives/src/settings-form/SettingsForm.tsx @@ -1,6 +1,6 @@ /** - * One plugin's configuration form as its page on the Plugins page shows it: - * the read-only notice when the deployment stores settings read-only, the + * One plugin's settings form as its page on the Plugins page shows it: the + * read-only notice when the deployment stores settings read-only, the * plugin's controls, and the save that writes every staged edit. The page * draws the plugin's title and one-liner itself. * @@ -11,16 +11,29 @@ */ import { useEffect, useRef, type ReactNode } from 'react' -import type { CardShell } from './card-form.ts' -import type { PluginsSettingsLocaleKey } from './locales.ts' -import css from './PluginConfigForm.module.css' +import type { SettingsFormShell } from './form-model.ts' +import css from './SettingsForm.module.css' -/** Form chrome shared by every plugin configuration page. */ -export interface PluginConfigFormProps { - /** Locale reader for this package's copy. */ - t: (key: PluginsSettingsLocaleKey) => string +/** The copy the form frame renders, from the owning plugin's dictionary. */ +export interface SettingsFormLabels { + /** Shown in place of the controls while the namespace is not served. */ + unavailable: string + /** Shown over the controls while the document is read-only. */ + readOnly: string + /** Shown beside the save after a save the Host did not accept. */ + saveFailed: string + /** The save control. */ + save: string + /** The save control while a save is crossing the wire. */ + saving: string +} + +/** Form chrome shared by every settings page. */ +export interface SettingsFormProps { + /** The frame's copy. */ + labels: SettingsFormLabels /** The form state: availability, writability, and what a save would do. */ - state: CardShell + state: SettingsFormShell /** Write every staged edit. */ onSave: () => void /** Drop every staged edit; the form calls it when it leaves the page. */ @@ -30,30 +43,30 @@ export interface PluginConfigFormProps { } /** - * Render one plugin's configuration form. - * @param props - the form state, its controls, and the save and discard actions. + * Render one plugin's settings form. + * @param props - the form's copy and state, its controls, and the save and discard actions. * @returns the form, or the unavailable line while the namespace is not served. */ -export function PluginConfigForm(props: PluginConfigFormProps) { - const { state } = props +export function SettingsForm(props: SettingsFormProps) { + const { state, labels } = props const discard = useRef(props.onDiscard) discard.current = props.onDiscard useEffect(() => () => { discard.current() }, []) - if (!state.available) return

{props.t('unavailable')}

+ if (!state.available) return

{labels.unavailable}

const blocked = !state.dirty || state.invalid || state.saving return (
- {!state.writable ?

{props.t('readOnly')}

: null} + {!state.writable ?

{labels.readOnly}

: null} {props.children}
- {state.failed ?

{props.t('saveFailed')}

: null} + {state.failed ?

{labels.saveFailed}

: null}
diff --git a/packages/client/ui-settings-plugins/src/client/fields.module.css b/packages/client/ui-primitives/src/settings-form/fields.module.css similarity index 96% rename from packages/client/ui-settings-plugins/src/client/fields.module.css rename to packages/client/ui-primitives/src/settings-form/fields.module.css index 4db120dc7c..6c637d95a6 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.module.css +++ b/packages/client/ui-primitives/src/settings-form/fields.module.css @@ -1,4 +1,4 @@ -/* Plugin configuration fields: label, control, override badge, and hint. */ +/* Settings form fields: label, control, override badge, and hint. */ .field { display: flex; diff --git a/packages/client/ui-settings-plugins/src/client/fields.tsx b/packages/client/ui-primitives/src/settings-form/fields.tsx similarity index 86% rename from packages/client/ui-settings-plugins/src/client/fields.tsx rename to packages/client/ui-primitives/src/settings-form/fields.tsx index 3610c694bc..163c1b971a 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.tsx +++ b/packages/client/ui-primitives/src/settings-form/fields.tsx @@ -1,17 +1,18 @@ /** - * Hand-written controls for the plugin configuration forms. Each renders one - * field's label, its staged text, whether saving would leave an override, and - * — when one stands — the reset that stages a clear back to the composition - * layer. Nothing here writes: a control reports what the user typed, and the - * card's save is the single point where a draft becomes a document mutation. + * The controls of a settings form. Each renders one field's label, its staged + * text, whether saving would leave an override, and — when one stands — the + * reset that stages a clear back to the composition layer. Nothing here + * writes: a control reports what the user typed, and the form's save is the + * single point where a draft becomes a document mutation. */ import { useState, type ReactNode } from 'react' -import { IconInfoOutline14, Tag } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconInfoOutline14 } from '../icons/index.tsx' +import { Tag } from '../Tag.tsx' import css from './fields.module.css' -/** What every field control needs regardless of its value type. */ -export interface FieldProps { +/** What every settings field control needs regardless of its value type. */ +export interface SettingsFieldProps { /** Stable id associating the label with its control. */ id: string /** Visible label. */ @@ -45,7 +46,7 @@ export interface FieldProps { * @param props - the field's copy, its staged text, and the edit actions. * @returns the labelled control. */ -export function ValueField(props: Omit & { +export function SettingsValueField(props: Omit & { /** Optional explanation shown below the input. */ hint?: string /** Rules disclosed by the information button beside the label. */ @@ -120,7 +121,7 @@ export function ValueField(props: Omit & { * @param props - the field's copy, its staged text, and the configured state. * @returns the labelled control. */ -export function SecretField(props: Pick & { +export function SettingsSecretField(props: Pick & { /** Whether the Host reports a configured credential for this reference. */ configured: boolean /** Copy describing the configured state. */ diff --git a/packages/client/ui-settings-plugins/src/client/card-form.ts b/packages/client/ui-primitives/src/settings-form/form-model.ts similarity index 73% rename from packages/client/ui-settings-plugins/src/client/card-form.ts rename to packages/client/ui-primitives/src/settings-form/form-model.ts index 365a2e8fd8..1d7af5daad 100644 --- a/packages/client/ui-settings-plugins/src/client/card-form.ts +++ b/packages/client/ui-primitives/src/settings-form/form-model.ts @@ -1,7 +1,7 @@ /** - * Shared form model behind every plugin card. + * The staged form model behind a plugin's settings page. * - * A card stages what the user types and writes it only when they save. Each + * A page stages what the user types and writes it only when they save. Each * settings write is a durable, revision-fenced document mutation, so a control * that committed as it settled turned one edit into a write the user never * asked for and could not preview; staged text makes what is on screen exactly @@ -14,15 +14,57 @@ */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' -/** The write one field's staged text performs when the card is saved. */ -export type FieldWrite = +/** What the model reads of one settings namespace. */ +export interface SettingsFormScopeSnapshot { + /** `ready` while the Host serves the namespace to this client; the form renders nothing otherwise. */ + status: 'loading' | 'ready' | 'unavailable' + /** Last accepted schema-resolved section; undefined before the first acceptance. */ + value: T | undefined + /** Composition layer the value resolves over: what a field reverts to once cleared. */ + base: unknown + /** Raw user layer as stored; a field's PRESENCE here is what marks it overridden. */ + user: unknown + /** Whether the Host document accepts writes. */ + writable: boolean +} + +/** + * The namespace scope the model stages over: the reads and the two field + * writes of the scope `ui-settings` binds for a namespace, which a page passes + * as it is. + */ +export interface SettingsFormScope { + /** @returns the current sync snapshot. */ + getSnapshot(): SettingsFormScopeSnapshot + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void + /** + * Queue one field write. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any recovery read. + */ + set(field: string, value: unknown): Promise + /** + * Queue one field clear, so the field re-inherits the composition layer. + * @param field - scalar field inside the namespace section. + * @returns settlement after the clear and any recovery read. + */ + unset(field: string): Promise +} + +/** The write one field's staged text performs when the form is saved. */ +export type SettingsFieldWrite = | { kind: 'set'; value: unknown } | { kind: 'clear' } /** How one section field converts between its stored value and its draft text. */ -export interface CardFieldSpec { +export interface SettingsFieldSpec { /** Field name inside the namespace section. */ field: string /** Render a stored value as draft text; the empty string when the section carries none. */ @@ -31,7 +73,7 @@ export interface CardFieldSpec { * The write this draft text stages, or undefined when the text is not a * value this field accepts — which blocks the save rather than discarding it. */ - parse: (text: string) => FieldWrite | undefined + parse: (text: string) => SettingsFieldWrite | undefined } /** @@ -39,15 +81,15 @@ export interface CardFieldSpec { * literal never rides a response, so its draft has nothing to seed from: it is * blank until typed, and a blank draft writes nothing. */ -export interface CardSecretSpec { - /** Field name addressing this control inside the card's form. */ +export interface SettingsSecretSpec { + /** Field name addressing this control inside the form. */ field: string /** Write the staged text; resolves to whether the Host accepted it. */ write: (text: string) => Promise } -/** One field as a card's control renders it. */ -export interface CardFieldState { +/** One field as a form's control renders it. */ +export interface SettingsFieldState { /** Draft text the control renders. */ text: string /** @@ -60,9 +102,9 @@ export interface CardFieldState { invalid: boolean } -/** Form state every plugin card shares. */ -export interface CardShell { - /** False while the namespace is not served to this client; the card renders nothing. */ +/** Form state every settings page shares. */ +export interface SettingsFormShell { + /** False while the namespace is not served to this client; the form renders nothing. */ available: boolean /** Whether the Host document accepts writes. */ writable: boolean @@ -76,8 +118,8 @@ export interface CardShell { failed: boolean } -/** The write actions every plugin card's slot entry injects. */ -export interface CardActions { +/** The write actions a settings page's slot entry injects. */ +export interface SettingsFormActions { /** Stage draft text for one field. */ edit: (field: string, text: string) => void /** Stage a clear, so saving lets the field re-inherit the composition layer. */ @@ -113,7 +155,7 @@ interface PlannedWrite { * @param field - field name inside the namespace section. * @returns the field's conversion spec. */ -export function numberField(field: string): CardFieldSpec { +export function settingsNumberField(field: string): SettingsFieldSpec { return { field, // A section that carries no number for this field renders empty rather @@ -134,7 +176,7 @@ export function numberField(field: string): CardFieldSpec { * @param field - field name inside the namespace section. * @returns the field's conversion spec. */ -export function textField(field: string): CardFieldSpec { +export function settingsTextField(field: string): SettingsFieldSpec { return { field, format: value => typeof value === 'string' ? value : '', @@ -146,29 +188,29 @@ export function textField(field: string): CardFieldSpec { } /** - * Stages one card's edits over one settings namespace and writes them on save. + * Stages one page's edits over one settings namespace and writes them on save. * * The form publishes through a snapshot store because slot components read * through a snapshot selector, while both the scope and the local drafts * change underneath; every projection is rebuilt from the two together. */ -export class CardForm { - private readonly specs: Map - private readonly secretSpecs: Map +export class SettingsFormModel { + private readonly specs: Map + private readonly secretSpecs: Map private readonly staged = new Map() private readonly listeners = new Set<() => void>() private saving = false private failed = false /** - * @param scope - the bound settings scope for this card's namespace. - * @param specs - the section fields this card edits. - * @param secrets - the card's write-only controls, written outside the section. + * @param scope - the bound settings scope for the page's namespace. + * @param specs - the section fields the page edits. + * @param secrets - the page's write-only controls, written outside the section. */ constructor( - private readonly scope: SettingsScope, - specs: CardFieldSpec[], - secrets: CardSecretSpec[] = [], + private readonly scope: SettingsFormScope, + specs: SettingsFieldSpec[], + secrets: SettingsSecretSpec[] = [], ) { this.specs = new Map(specs.map(spec => [spec.field, spec])) this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec])) @@ -177,8 +219,8 @@ export class CardForm { /** * Publish a projection of this form, rebuilt whenever the scope or a draft changes. - * @param project - build the card's state from the form's current reads. - * @returns the store the card's component reads through its bound selector. + * @param project - build the page's state from the form's current reads. + * @returns the store the page's component reads through its bound selector. */ bind(project: () => S): SnapshotStore { const store = createSnapshotStore(project()) @@ -187,10 +229,10 @@ export class CardForm { } /** - * Read the card-level state: what the Host serves, and what a save would do. - * @returns the form state every card shares. + * Read the form-level state: what the Host serves, and what a save would do. + * @returns the form state every page shares. */ - shell(): CardShell { + shell(): SettingsFormShell { const snapshot = this.scope.getSnapshot() const plan = this.plan() return { @@ -208,7 +250,7 @@ export class CardForm { * @param field - field name of a section field or of a write-only control. * @returns the draft text, whether a save would leave an override, and whether it is invalid. */ - field(field: string): CardFieldState { + field(field: string): SettingsFieldState { const staged = this.staged.get(field) if (this.secretSpecs.has(field)) { return { text: staged?.text ?? '', overridden: false, invalid: false } @@ -227,9 +269,9 @@ export class CardForm { /** * Build the edit, reset, save, and discard actions bound to this form. - * @returns the actions a card's slot entry injects. + * @returns the actions a page's slot entry injects. */ - actions(): CardActions { + actions(): SettingsFormActions { return { edit: (field, text) => { this.stage(field, { text, clear: false }) }, resetField: (field) => { @@ -316,15 +358,15 @@ export class CardForm { this.publish() } - private spec(field: string): CardFieldSpec { + private spec(field: string): SettingsFieldSpec { const spec = this.specs.get(field) - // Every call site names a field this card declared; a missing one is a + // Every call site names a field the page declared; a missing one is a // wiring mistake that must not degrade into a silently inert control. - if (spec === undefined) throw new Error(`plugin card has no field ${field}`) + if (spec === undefined) throw new Error(`settings form has no field ${field}`) return spec } - private snapshotOf(): SettingsScopeSnapshot { + private snapshotOf(): SettingsFormScopeSnapshot { return this.scope.getSnapshot() } diff --git a/packages/client/ui-settings-plugins/tests/fields.client.spec.tsx b/packages/client/ui-primitives/tests/settings-fields.client.spec.tsx similarity index 79% rename from packages/client/ui-settings-plugins/tests/fields.client.spec.tsx rename to packages/client/ui-primitives/tests/settings-fields.client.spec.tsx index d9a8d29ac2..a1de1f0b6e 100644 --- a/packages/client/ui-settings-plugins/tests/fields.client.spec.tsx +++ b/packages/client/ui-primitives/tests/settings-fields.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { SecretField, ValueField } from '../src/client/fields.tsx' +import { SettingsSecretField, SettingsValueField } from '../src/settings-form/fields.tsx' afterEach(cleanup) @@ -18,10 +18,10 @@ const frame = { invalid: false, } -describe('ValueField', () => { +describe('SettingsValueField', () => { it('stages every keystroke without writing', () => { const onEdit = vi.fn() - render() + render() fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } }) @@ -29,20 +29,20 @@ describe('ValueField', () => { }) it('renders the staged text it is given rather than a draft of its own', () => { - const { rerender } = render() + const { rerender } = render() expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000') - rerender() + rerender() expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000') }) it('offers the reset only while an override would stand', () => { const onReset = vi.fn() - const { rerender } = render() + const { rerender } = render() expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull() - rerender() + rerender() fireEvent.click(screen.getByRole('button', { name: 'Reset to default' })) expect(screen.getByText('Overridden')).toBeTruthy() @@ -50,7 +50,7 @@ describe('ValueField', () => { }) it('replaces the hint with the reason an invalid draft cannot be saved', () => { - render() + render() expect(screen.getByText('Enter a number.')).toBeTruthy() expect(screen.queryByText('How long one command may run.')).toBeNull() @@ -59,7 +59,7 @@ describe('ValueField', () => { it('hints a numeric keypad and renders a placeholder when asked', () => { render( - { }) it('disables the control and its reset while the document is read-only', () => { - render() + render() expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true) }) }) -describe('SecretField', () => { +describe('SettingsSecretField', () => { const secret = { id: 'key', label: 'API key', @@ -93,7 +93,7 @@ describe('SecretField', () => { it('stages the draft and never renders it', () => { const onEdit = vi.fn() render( - { it('reports the configured state the Host holds', () => { const { rerender } = render( - { expect(screen.getByText('No key is configured.')).toBeTruthy() rerender( - { it('disables the control when it is told to', () => { render( - { + scope: SettingsFormScope + set: ReturnType Promise>> + unset: ReturnType Promise>> + publish: (next: Partial>) => void +} + +function stubScope(): StubScope { + let snapshot: SettingsFormScopeSnapshot = { status: 'loading', value: undefined, base: undefined, user: undefined, writable: false } + const listeners = new Set<() => void>() + const set = vi.fn<(field: string, value: unknown) => Promise>(() => Promise.resolve()) + const unset = vi.fn<(field: string) => Promise>(() => Promise.resolve()) + return { + scope: { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set, + unset, + }, + set, + unset, + publish: (next) => { + snapshot = { ...snapshot, ...next } + for (const listener of [...listeners]) listener() + }, + } +} + +/** Make the stub behave like a Host that accepts every write. */ +function acceptWrites(host: StubScope): void { + const section = (): Record => ({ ...host.scope.getSnapshot().value as object }) + const layer = (): Record => ({ ...host.scope.getSnapshot().user as object }) + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) + return Promise.resolve() + }) + host.unset.mockImplementation((field: string) => { + const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) + const base = host.scope.getSnapshot().base as Record | undefined + host.publish({ value: { ...section(), [field]: base?.[field] } as T, user }) + return Promise.resolve() + }) +} + +describe('SettingsFormModel', () => { + function form() { + const host = stubScope>() + const subject = new SettingsFormModel(host.scope, [settingsNumberField('timeoutMs'), settingsTextField('baseURL')]) + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, + base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, + user: {}, + }) + return { host, subject } + } + + it('shows the effective value and stays clean until something is staged', () => { + const { subject } = form() + + expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) + expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false }) + }) + + it('marks a field the user layer carries as overridden', () => { + const { host, subject } = form() + + host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } }) + + // An override equal to the composition default is still an override. + expect(subject.field('timeoutMs').overridden).toBe(true) + }) + + it('writes nothing until the form is saved', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('timeoutMs', '9000') + + expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false }) + expect(subject.shell().dirty).toBe(true) + expect(host.set).not.toHaveBeenCalled() + + await subject.save() + + expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]]) + expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false }) + }) + + it('drops a draft that settles back on the value already shown', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + subject.actions().edit('timeoutMs', '60000') + + expect(subject.shell().dirty).toBe(false) + await subject.save() + + expect(host.set).not.toHaveBeenCalled() + }) + + it('refuses to save while a draft is not a value the field accepts', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', 'soon') + + expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true }) + expect(subject.shell()).toMatchObject({ dirty: true, invalid: true }) + + await subject.save() + + expect(host.set).not.toHaveBeenCalled() + expect(subject.field('timeoutMs').text).toBe('soon') + }) + + it('stages a reset that clears the field only once saved', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } }) + + subject.actions().resetField('timeoutMs') + + // The badge previews the save: the field will no longer be overridden. + expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) + expect(host.unset).not.toHaveBeenCalled() + + await subject.save() + + expect(host.unset.mock.calls).toEqual([['timeoutMs']]) + expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) + }) + + it('treats resetting an inherited field as no change at all', async () => { + const { host, subject } = form() + + subject.actions().resetField('timeoutMs') + + expect(subject.shell().dirty).toBe(false) + await subject.save() + + expect(host.unset).not.toHaveBeenCalled() + }) + + it('clears a number field by emptying it', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ user: { timeoutMs: 9_000 } }) + + subject.actions().edit('timeoutMs', '') + + expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false }) + await subject.save() + + expect(host.unset.mock.calls).toEqual([['timeoutMs']]) + }) + + it('clears a text field by emptying it', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ user: { baseURL: 'https://search.test/v1' } }) + + subject.actions().edit('baseURL', ' ') + await subject.save() + + expect(host.unset.mock.calls).toEqual([['baseURL']]) + }) + + it('writes the trimmed text of a text field', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('baseURL', ' https://other.test ') + await subject.save() + + expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']]) + }) + + it('keeps the drafts a save did not land, and reports the failure', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + await subject.save() + + // The stub Host accepted the call without storing it, exactly as a + // validator that refuses the value does. + expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000) + expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false }) + expect(subject.field('timeoutMs').text).toBe('9000') + }) + + it('reports a reset the Host did not apply as a failure', async () => { + const { host, subject } = form() + host.publish({ user: { timeoutMs: 9_000 } }) + + subject.actions().resetField('timeoutMs') + await subject.save() + + expect(host.unset).toHaveBeenCalledWith('timeoutMs') + expect(subject.shell().failed).toBe(true) + }) + + it('clears the failure as soon as the user edits again', async () => { + const { subject } = form() + + subject.actions().edit('timeoutMs', '9000') + await subject.save() + expect(subject.shell().failed).toBe(true) + + subject.actions().edit('timeoutMs', '9001') + + expect(subject.shell().failed).toBe(false) + }) + + it('discards every staged edit', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + subject.actions().discard() + + expect(subject.field('timeoutMs').text).toBe('60000') + expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) + + // A discard with nothing staged publishes nothing. + const before = subject.shell() + subject.actions().discard() + expect(subject.shell()).toEqual(before) + + await subject.save() + expect(host.set).not.toHaveBeenCalled() + }) + + it('refuses a second save while one is in flight', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('timeoutMs', '9000') + const first = subject.save() + expect(subject.shell().saving).toBe(true) + const second = subject.save() + await Promise.all([first, second]) + + expect(host.set).toHaveBeenCalledTimes(1) + }) + + it('publishes a projection whenever the scope or a draft changes', () => { + const { host, subject } = form() + const store = subject.bind(() => subject.field('timeoutMs').text) + expect(store.getSnapshot()).toBe('60000') + + host.publish({ value: { timeoutMs: 1_000 } }) + expect(store.getSnapshot()).toBe('1000') + + subject.actions().edit('timeoutMs', '2000') + expect(store.getSnapshot()).toBe('2000') + }) + + it('refuses to address a field the card never declared', () => { + const { subject } = form() + + expect(() => subject.field('nope')).toThrow('settings form has no field nope') + }) + + it('renders an absent section value as an empty draft', () => { + const host = stubScope>() + const subject = new SettingsFormModel(host.scope, [settingsNumberField('timeoutMs'), settingsTextField('baseURL')]) + + host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined }) + + expect(subject.field('timeoutMs').text).toBe('') + expect(subject.field('baseURL').text).toBe('') + expect(subject.shell().available).toBe(true) + }) + + it('stays unavailable while the namespace is not served', () => { + const host = stubScope>() + const subject = new SettingsFormModel(host.scope, [settingsNumberField('timeoutMs')]) + + host.publish({ status: 'unavailable' }) + + expect(subject.shell()).toMatchObject({ available: false, writable: false }) + }) +}) diff --git a/packages/client/ui-primitives/tests/settings-form.client.spec.tsx b/packages/client/ui-primitives/tests/settings-form.client.spec.tsx new file mode 100644 index 0000000000..087713d9a1 --- /dev/null +++ b/packages/client/ui-primitives/tests/settings-form.client.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** The settings form frame: what it says in each state, and that leaving it discards. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { SettingsForm } from '../src/settings-form/SettingsForm.tsx' +import type { SettingsFormShell } from '../src/settings-form/form-model.ts' + +afterEach(cleanup) + +const labels = { + unavailable: 'Not loaded.', + readOnly: 'Read-only.', + saveFailed: 'Not accepted.', + save: 'Save', + saving: 'Saving…', +} + +const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + +function renderForm(state: Partial = {}) { + const onSave = vi.fn() + const onDiscard = vi.fn() + render( + + + , + ) + return { onSave, onDiscard } +} + +it('says the namespace is not served in place of the controls', () => { + renderForm({ available: false }) + + expect(screen.getByRole('status').textContent).toBe(labels.unavailable) + expect(screen.queryByLabelText('Field')).toBeNull() +}) + +it('keeps the save inert until something is staged, then saves on click', () => { + const { onSave } = renderForm() + expect(screen.getByRole('button', { name: labels.save })).toHaveProperty('disabled', true) + cleanup() + + const dirty = renderForm({ dirty: true }) + fireEvent.click(screen.getByRole('button', { name: labels.save })) + expect(dirty.onSave).toHaveBeenCalledOnce() + expect(onSave).not.toHaveBeenCalled() +}) + +it('blocks the save while a draft is invalid or one is in flight, and reports a refused save', () => { + renderForm({ dirty: true, invalid: true }) + expect(screen.getByRole('button', { name: labels.save })).toHaveProperty('disabled', true) + cleanup() + + renderForm({ dirty: true, saving: true }) + expect(screen.getByRole('button', { name: labels.saving })).toHaveProperty('disabled', true) + cleanup() + + renderForm({ dirty: true, failed: true }) + expect(screen.getByRole('status').textContent).toBe(labels.saveFailed) +}) + +it('says the document is read-only over the controls', () => { + renderForm({ writable: false }) + + expect(screen.getByRole('status').textContent).toBe(labels.readOnly) + expect(screen.getByLabelText('Field')).toBeTruthy() +}) + +it('discards when it leaves the page, calling the latest discard it was given', () => { + const first = vi.fn() + const second = vi.fn() + const { rerender, unmount } = render( + , + ) + rerender() + + unmount() + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-primitives/tsconfig.json b/packages/client/ui-primitives/tsconfig.json index efd9866d5e..201a8a711e 100644 --- a/packages/client/ui-primitives/tsconfig.json +++ b/packages/client/ui-primitives/tsconfig.json @@ -9,5 +9,10 @@ ], "exclude": [ "**/*.legacy.*" + ], + "references": [ + { + "path": "../store" + } ] } diff --git a/packages/client/ui-settings-agent-loop/README.i18n.yaml b/packages/client/ui-settings-agent-loop/README.i18n.yaml new file mode 100644 index 0000000000..92ec6f1516 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-settings-agent-loop/README.md +README.md: 725a28c2ec708c864702486a9ab8fc4b7f153dce +README.zh.md: 9792bf01dc510c4b842124b0d1999ac3739552f8 diff --git a/packages/client/ui-settings-agent-loop/README.md b/packages/client/ui-settings-agent-loop/README.md new file mode 100644 index 0000000000..725a28c2ec --- /dev/null +++ b/packages/client/ui-settings-agent-loop/README.md @@ -0,0 +1,78 @@ +--- +description: "The agent loop's settings page on the dsh web client's Plugins page: the parallel tool-call cap of the agent-loop namespace." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-agent-loop + +English | [中文](README.zh.md) + +## Summary + +Open **Plugins** in the sidebar and select **Agent loop** in the Official group to set how many parallel-safe tool calls one step may run at once. The page stages what is typed and writes it only on save, marks a value the user overrode, and offers to reset it to the deployment's default. The page exists while the Host serves the `agent-loop` namespace. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The **Agent loop** card in the Official group opens the page. **Parallel tool calls** bounds the calls the loop runs at once within one step; the field renders the effective value, and once overridden carries an **Overridden** badge with **Reset to default** beside it. Nothing is written until **Save**; leaving the page drops the draft, an empty field saves as a reset, and text that is not a number blocks the save and says so under the field. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `agent-loop` namespace through `ctx.settingsScope`, keeps the staged form in `AgentLoopCardController` over the shared `SettingsFormModel` of `ui-primitives`, and registers `AgentLoopCard` into the Plugins page's `plugins.item` slot through `ctx.settingsScope.whileServed`. The page's copy lives in this package's `settings.agentLoop` dictionary. + +
+ +----- + + +## Further Exploration + +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. +- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. +- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the page renders. +- [agent-loop](../../core/agent-loop/README.md) — the loop that registers the `agent-loop` namespace. + +----- + + +## Model Experience + +None, as the package is a browser-side settings surface that registers no model surface. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **One field of the namespace** — the Host section carries only the parallel cap; the composed `agents` array is boot composition, not a user setting, and has no field here. +- **Runtime invariant:** No companion is published. The page holds no owned relationship of its own: what it shows derives from the settings mirror, and what it writes the Host validates. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-settings-agent-loop/README.zh.md b/packages/client/ui-settings-agent-loop/README.zh.md new file mode 100644 index 0000000000..9792bf01dc --- /dev/null +++ b/packages/client/ui-settings-agent-loop/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "dsh Web 客户端插件页上的 Agent 循环设置页:agent-loop 命名空间的并行工具调用上限。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-agent-loop + +[English](README.md) | 中文 + +## 概述 + +在侧栏打开**插件**,在官方分组里选择 **Agent 循环**,即可设置一步之内最多同时运行多少个可并行的工具调用。页面暂存输入、只在保存时写入,标明用户覆盖过的值,并允许把它重置回部署默认值。页面只在 Host 服务 `agent-loop` 命名空间期间存在。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +官方分组里的 **Agent 循环**卡片打开这一页。**并行工具调用数**限制循环在一步之内同时运行的调用数;字段显示生效值,一旦被覆盖就带**已覆盖**标签,旁边是**恢复默认**。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿,清空字段并保存等于重置,填了不是数字的文本则保存被阻止,并在字段下说明原因。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.settingsScope` 绑定 `agent-loop` 命名空间,用 `ui-primitives` 的共享 `SettingsFormModel` 在 `AgentLoopCardController` 里维护暂存表单,并通过 `ctx.settingsScope.whileServed` 把 `AgentLoopCard` 注册进插件页的 `plugins.item` slot。页面文案在本包的 `settings.agentLoop` 字典里。 + +
+ +----- + + +## 进一步探索 + +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 +- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 +- [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。 +- [agent-loop](../../core/agent-loop/README.zh.md)——注册 `agent-loop` 命名空间的循环。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的设置界面,不注册任何模型面。 + +#### KV 缓存影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **只编辑命名空间的一个字段**——Host 的这一节只承载并行上限;组合进来的 `agents` 数组属于启动组合而非用户设置,这里没有对应字段。 +- **运行时不变量:**不发布伴生。本页没有自己拥有的关系:它显示的内容派生自设置镜像,它写入的内容由 Host 校验。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-settings-agent-loop/package.json b/packages/client/ui-settings-agent-loop/package.json new file mode 100644 index 0000000000..7a4f26ec1c --- /dev/null +++ b/packages/client/ui-settings-agent-loop/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-agent-loop", + "description": "Settings page of the agent loop on the dsh web client's Plugins page: the parallel tool-call cap of the agent-loop namespace", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-agent-loop" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx b/packages/client/ui-settings-agent-loop/src/client/AgentLoopCard.tsx similarity index 63% rename from packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx rename to packages/client/ui-settings-agent-loop/src/client/AgentLoopCard.tsx index 02c702cb9e..37eba283b1 100644 --- a/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx +++ b/packages/client/ui-settings-agent-loop/src/client/AgentLoopCard.tsx @@ -1,37 +1,32 @@ -/** The agent loop's configuration page: how many tool calls one step may run at once. */ +/** The agent loop's settings page: how many tool calls one step may run at once. */ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import { SettingsForm, SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { ValueField } from './fields.tsx' -import { PluginConfigForm } from './PluginConfigForm.tsx' +import { formLabels } from './locales.ts' import type { AgentLoopCardFace } from './agent-loop-card-controller.ts' /** Props the renderer binds for the agent-loop page. */ export type AgentLoopCardProps = PropsRuntime<'plugins.item'> - & PropsLocale<'settings.plugins'> + & PropsLocale<'settings.agentLoop'> & InjectFace /** - * Render the agent loop's one-liner or its configuration form, as the Plugins page asks. + * Render the agent loop's one-liner or its settings form, as the Plugins page asks. * @param props - the view asked for, locale copy, the form snapshot, and its actions. * @returns the one-liner, or the form. */ export function AgentLoopCard(props: AgentLoopCardProps) { const { t } = props const state = props.useAgentLoopCard(snapshot => snapshot) - if (props.view === 'summary') return t('agentLoopDescription') + if (props.view === 'summary') return t('description') return ( - - + { props.edit('maxParallelToolCalls', text) }} onReset={() => { props.resetField('maxParallelToolCalls') }} /> - + ) } diff --git a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts b/packages/client/ui-settings-agent-loop/src/client/agent-loop-card-controller.ts similarity index 52% rename from packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts rename to packages/client/ui-settings-agent-loop/src/client/agent-loop-card-controller.ts index d37d59b394..edc15e0b79 100644 --- a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts +++ b/packages/client/ui-settings-agent-loop/src/client/agent-loop-card-controller.ts @@ -1,8 +1,10 @@ -/** The agent-loop card's staged form over the `agent-loop` settings namespace. */ +/** The agent-loop page's staged form over the `agent-loop` settings namespace. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' +import { + SettingsFormModel, settingsNumberField, + type SettingsFieldState, type SettingsFormActions, type SettingsFormScope, type SettingsFormShell, +} from '@deepseek-ai/dsh-client-ui-primitives' /** * Namespace of the agent loop's user-owned settings. Spelled here rather than @@ -11,7 +13,7 @@ import { CardForm, numberField, type CardActions, type CardFieldState, type Card export const AGENT_LOOP_NS = 'agent-loop' /** - * The agent-loop fields this card edits. The Host section carries only this + * The agent-loop fields this page edits. The Host section carries only this * field — the composed `agents` array is deliberately not part of it. */ export interface AgentLoopSettings { @@ -19,28 +21,28 @@ export interface AgentLoopSettings { maxParallelToolCalls?: number } -/** What the agent-loop card renders. */ -export interface AgentLoopCardState extends CardShell { +/** What the agent-loop page renders. */ +export interface AgentLoopCardState extends SettingsFormShell { /** Parallel tool-call cap. */ - maxParallelToolCalls: CardFieldState + maxParallelToolCalls: SettingsFieldState } -/** The registration-side face the agent-loop card's slot entry injects. */ -export interface AgentLoopCardFace extends CardActions { +/** The registration-side face the agent-loop page's slot entry injects. */ +export interface AgentLoopCardFace extends SettingsFormActions { hooks: { - /** Card snapshot bound by the renderer as useAgentLoopCard. */ + /** Page snapshot bound by the renderer as useAgentLoopCard. */ agentLoopCard: SnapshotStore } } -/** Bridges the `agent-loop` scope onto the card's staged form. */ +/** Bridges the `agent-loop` scope onto the page's staged form. */ export class AgentLoopCardController { - private readonly form: CardForm + private readonly form: SettingsFormModel private readonly store: SnapshotStore /** @param scope - the bound settings scope for the `agent-loop` namespace. */ - constructor(scope: SettingsScope) { - this.form = new CardForm(scope, [numberField('maxParallelToolCalls')]) + constructor(scope: SettingsFormScope) { + this.form = new SettingsFormModel(scope, [settingsNumberField('maxParallelToolCalls')]) this.store = this.form.bind(() => this.projection()) } @@ -49,8 +51,8 @@ export class AgentLoopCardController { } /** - * Build the face the card's slot registration injects. - * @returns the card's snapshot and its form actions. + * Build the face the page's slot registration injects. + * @returns the page's snapshot and its form actions. */ inject(): AgentLoopCardFace { return { hooks: { agentLoopCard: this.store }, ...this.form.actions() } diff --git a/packages/client/ui-settings-agent-loop/src/client/index.ts b/packages/client/ui-settings-agent-loop/src/client/index.ts new file mode 100644 index 0000000000..5c3d4915ad --- /dev/null +++ b/packages/client/ui-settings-agent-loop/src/client/index.ts @@ -0,0 +1,49 @@ +/** + * The agent loop's settings page, browser half: the parallel tool-call cap + * over the `agent-loop` namespace the loop registers. The page registers into + * the Plugins page's `plugins.item` slot while the Host serves that namespace, + * so a deployment that exposes no agent-loop settings shows no trace of it. + */ + +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { AgentLoopCard } from './AgentLoopCard.tsx' +import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' +import { en, zh, type AgentLoopSettingsLocaleKey } from './locales.ts' + +export type { AgentLoopCardProps } from './AgentLoopCard.tsx' +export type { AgentLoopCardFace, AgentLoopCardState, AgentLoopSettings } from './agent-loop-card-controller.ts' +export type { AgentLoopSettingsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Agent loop settings page copy. */ + 'settings.agentLoop': AgentLoopSettingsLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.agentLoop' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'settingsScope'] + +/** + * Mount the agent loop's settings page while the Host serves its namespace. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-agent-loop: dictionaries') + const card = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) + ctx.effect(() => ctx.settingsScope.whileServed([AGENT_LOOP_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'agent-loop', order: 20, label: () => t('title'), locale: NS, inject: () => card.inject(), + }, AgentLoopCard))), 'ui-settings-agent-loop: page') +} diff --git a/packages/client/ui-settings-agent-loop/src/client/locales.ts b/packages/client/ui-settings-agent-loop/src/client/locales.ts new file mode 100644 index 0000000000..609abf64a0 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/src/client/locales.ts @@ -0,0 +1,50 @@ +/** Locale bundles for the agent loop's settings page. */ + +import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Locale keys the page renders. */ +export type AgentLoopSettingsLocaleKey = + | 'title' | 'description' | 'maxParallel' | 'maxParallelHint' + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' + +/** English copy. */ +export const en: Record = { + title: 'Agent loop', + description: 'How the agent dispatches tool calls.', + maxParallel: 'Parallel tool calls', + maxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.', + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + save: 'Save', + saving: 'Saving…', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + invalidNumber: 'Enter a number, or leave blank to use the default.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + title: 'Agent 循环', + description: 'Agent 如何派发工具调用。', + maxParallel: '并行工具调用数', + maxParallelHint: '同一步内最多同时运行多少个可并行的调用。', + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + unavailable: '该插件当前未加载,暂时无法配置。', + save: '保存', + saving: '保存中…', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + invalidNumber: '请填数字;留空表示使用默认值。', +} + +/** + * The form frame's copy, read from this page's dictionary. + * @param t - the page's locale reader. + * @returns the labels the shared settings form renders. + */ +export function formLabels(t: (key: AgentLoopSettingsLocaleKey) => string): SettingsFormLabels { + return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') } +} diff --git a/packages/client/ui-settings-agent-loop/src/css-modules.d.ts b/packages/client/ui-settings-agent-loop/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-settings-agent-loop/src/index.ts b/packages/client/ui-settings-agent-loop/src/index.ts new file mode 100644 index 0000000000..ec90addf90 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/src/index.ts @@ -0,0 +1,10 @@ +/** + * Agent loop settings page, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half owns the page + * through exports["./client"], discovered from the package.json dsh.client + * declaration. The `agent-loop` namespace the page edits is registered by the + * agent loop, so this package registers no namespace of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts b/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts new file mode 100644 index 0000000000..b822607711 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts @@ -0,0 +1,95 @@ +/** What the browser half registers, when, and that it all leaves with the fiber. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject, NS } from '../src/client/index.ts' +import type { AgentLoopCardFace } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' + +/** One Host view of a served namespace. */ +function view(ns: string, revision = 0) { + return { ns, schema: {}, value: {}, applies: 'live', secrets: [], revision } +} + +/** @param served - namespaces the Host describes; omitted answers a failed read. */ +async function bench(served?: string[]) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + locale.setLocale('zh') + ctx.provide('locale', locale) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } + : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const remote = new TestRemote(ctx, { settings: { describe: describeSettings } }) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeSettings, remote } +} + +/** The Plugins page's item slot, as its owner declares it. */ +function declareRoot(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'plugins.item': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-agent-loop apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'settingsScope']) + }) + + it('registers the page while the Host serves the namespace, titled in the active locale', async () => { + const { ctx, slots } = await bench(['agent-loop', 'shell']) + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + const entry = slots.entries('plugins.item')[0]! + expect(entry.options).toMatchObject({ id: 'agent-loop', order: 20 }) + expect(resolveSlotLabel(entry.options.label)).toBe('Agent 循环') + expect(entry.locale).toBe(NS) + const face = (entry.inject as unknown as () => AgentLoopCardFace)() + expect(Object.keys(face.hooks)).toEqual(['agentLoopCard']) + }) + + it('registers nothing while the namespace is not served, and withdraws the page when the Host stops serving it', async () => { + const { ctx, slots, describeSettings, remote } = await bench(['shell']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + expect(slots.entries('plugins.item')).toHaveLength(0) + + describeSettings.mockResolvedValue({ + ok: true, value: { writable: true, hasDocument: true, namespaces: [view('agent-loop', 1)] }, + }) + remote.emit('settings/document-updated', ['agent-loop', 1]) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + describeSettings.mockResolvedValue({ ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }) + remote.emit('settings/document-updated', ['agent-loop', 2]) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(0) }) + }) + + it('collapses the page on teardown', async () => { + const { ctx, slots } = await bench(['agent-loop']) + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + await fiber.dispose() + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx b/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx new file mode 100644 index 0000000000..be2fd9a09a --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +/** The agent-loop page as the Plugins page renders it: its one-liner and its one field. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' +import { AgentLoopCard, type AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' +import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + +function field(text: string, rest: Partial = {}): SettingsFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +function renderCard(state: Partial = {}, view: 'summary' | 'page' = 'page') { + const store = createSnapshotStore({ ...settled, maxParallelToolCalls: field('10'), ...state }) + const actions = { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } + const props = { ...actions, view, t, useAgentLoopCard: bindSnapshotSelector(store) } as unknown as AgentLoopCardProps + render() + return actions +} + +describe('AgentLoopCard', () => { + it('renders its one-liner alone in the summary view', () => { + renderCard({}, 'summary') + + expect(document.body.textContent).toBe(en.description) + expect(screen.queryByLabelText(en.maxParallel)).toBeNull() + }) + + it('stages and saves the only field it owns', () => { + const actions = renderCard({ dirty: true }) + + fireEvent.change(screen.getByLabelText(en.maxParallel), { target: { value: '2' } }) + fireEvent.click(screen.getByRole('button', { name: en.save })) + + expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2') + expect(actions.save).toHaveBeenCalledOnce() + }) + + it('stages a reset for the field it owns', () => { + const actions = renderCard({ maxParallelToolCalls: field('2', { overridden: true }) }) + + fireEvent.click(screen.getByRole('button', { name: en.reset })) + + expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls') + }) + + it('says the plugin is not loaded while its namespace is unavailable, and disables the field while the document is read-only', () => { + renderCard({ available: false }) + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + cleanup() + + renderCard({ writable: false }) + expect(screen.getByRole('status').textContent).toBe(en.readOnly) + expect(screen.getByLabelText(en.maxParallel)).toHaveProperty('disabled', true) + }) +}) diff --git a/packages/client/ui-settings-agent-loop/tests/controller.client.spec.ts b/packages/client/ui-settings-agent-loop/tests/controller.client.spec.ts new file mode 100644 index 0000000000..76eaa5df27 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tests/controller.client.spec.ts @@ -0,0 +1,41 @@ +/** The agent-loop page's form over a scripted settings scope. */ + +import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' + +describe('AgentLoopCardController', () => { + it('saves the only field it owns', async () => { + const host = stubSettingsScope() + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { [field]: value }, user: { [field]: value } }) + }) + const controller = new AgentLoopCardController(host.scope) + host.publish({ + status: 'ready', + writable: true, + value: { maxParallelToolCalls: 10 }, + base: { maxParallelToolCalls: 10 }, + user: {}, + }) + const face = controller.inject() + + face.edit('maxParallelToolCalls', '4') + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) }) + + expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({ + dirty: false, + maxParallelToolCalls: { text: '4', overridden: true }, + }) + }) + + it('reports a read-only document so the page can disable its controls', () => { + const host = stubSettingsScope() + const controller = new AgentLoopCardController(host.scope) + + host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } }) + + expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false) + }) +}) diff --git a/packages/client/ui-settings-agent-loop/tsconfig.json b/packages/client/ui-settings-agent-loop/tsconfig.json new file mode 100644 index 0000000000..a43ca3698f --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-plugin-manager" + }, + { + "path": "../ui-settings" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-settings-agent-loop/tsdown.config.ts b/packages/client/ui-settings-agent-loop/tsdown.config.ts new file mode 100644 index 0000000000..7ae5a8134a --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-agent-loop', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 22a2db2519..043c8a60bb 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md -README.md: 7d09cfb4f7d77cb8a121e043d94b62cd4ccd823f -README.zh.md: 649c5c51219856e8d8544db0d4d291343db55c64 +README.md: b4b3d37bd9da0fa6d1e3dcefd4d26394d4dde161 +README.zh.md: d22a9ec3f142bd7c11b27b1cb08e0a8a56ce6ead diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 7d09cfb4f7..b4b3d37bd9 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -1,5 +1,5 @@ --- -description: "Built-in plugins settings section for the dsh web client, and the official plugin configuration pages that register into the Plugins page." +description: "Built-in plugins settings section for the dsh web client: the Settings navigation entry and the tab chrome that feature-owned tabs register into." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use the **Built-in plugins** settings section to inspect the plugins this deployment ships, and the **Official** group of the sidebar's Plugins page to configure the host-plane plugins that expose settings. Each configuration page shows which values the user overrode, lets them reset those to deployment defaults, keeps edits local until save, and drops them when the page is left. If the configuration changed after the page loaded, the save is rejected instead of overwriting the newer values. +Use the **Built-in plugins** settings section to inspect the plugins this deployment ships. The section is a shell: it owns the navigation entry and the tab row, and every tab in it is registered by another plugin — the read-only inventory ships one. Configuring a built-in plugin happens on the sidebar's Plugins page, where each official plugin's own companion package registers its page. ## Table of Contents @@ -25,25 +25,9 @@ Use the **Built-in plugins** settings section to inspect the plugins this deploy ## Use this package -Open **Built-in plugins** in Settings for the read-only inventory; [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) contributes it as the section's one tab, shown as the page itself. To configure a host-plane plugin, select **Plugins** in the sidebar: the Official group lists one card per plugin this deployment composes, in this order — the shell executor (`shell`), the agent loop's tool-call parallelism (`agent-loop`), Subagent delegation limits and model selection (`subagent` and `subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`) — and a card opens the plugin's page with its form. +Open **Built-in plugins** in Settings. [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) contributes the inventory as the section's one tab, shown as the page itself; a second registered tab turns the row into tabs. A deployment whose composition contributes no tab shows the section's empty line. -### What appears here - -Each page registers into the Plugins page's `plugins.item` slot while the Host serves its settings namespace, so a deployment that does not compose the owning plugin shows no trace of it, and a namespace the Host starts or stops serving adds or withdraws its page on the next settings-document commit or reconnect. The card's one-liner and the page's form are one entry rendered in the two views the Plugins page asks for. - -### Editing and saving - -A page stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store. Leaving the page drops the drafts; there is no discard control. A failed save keeps the page as it is, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted. - -The **Subagent** card groups delegation limits and model selection on one page with one save button. **Maximum recursion depth** and **Subagent parallelism limit** appear side by side, stacking on narrow screens. Information buttons reveal a two-row depth example and the shared count rule; validation errors remain visible below the input. Depth retains explicit tool overrides. Capacity counts live continuable descendants across all recursion levels, including waiting children and excluding the root, one-shot runs, and external providers. Saving applies to later delegation attempts; lowering capacity does not stop existing children. - -The model selection section stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects. - -Saving the Subagent card validates both sections and writes their drafts through their existing namespaces. These writes are independent: if one fails, the card stays open with that draft retained, and retry writes only the remaining draft. The page appears when either namespace is served and shows only the available sections. - -### Secret-role fields - -A key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key. +To contribute a tab, register into `settings.plugins.tab` with an `id`, an `order`, and a localized `label`; the section renders the entries in order and mounts a tab on its first selection. Feature copy stays in the registering plugin's dictionary. ----- @@ -53,15 +37,7 @@ A key control starts blank, reports only whether one is configured, and writes t
Implementation internals — click to expand -The package is one registration rule and one write path: each page is registered while its namespace is served, and saves go through the client settings scope. - -### The registration rule - -The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs; a lone contribution renders as the page itself, and a tab stays mounted after its first selection so search and the inventory snapshot survive switching. The configuration pages are `plugins.item` registrations, one per namespace, made through `ctx.slots.inject` when the shared settings mirror shows the Host serves the namespace and disposed when it stops; registration order is the page order, not the Host's description order, which follows plugin activation and can change between boots. A page owns its controls and copy; the Plugins page draws its title, icon, and crumb. - -### The write path - -Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the page re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. +The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs; a lone contribution renders as the page itself, and a tab stays mounted after its first selection so search and the inventory snapshot survive switching. The section's `inject` projects the slot's ledger into ordered rows whose labels follow the active locale, cached until the ledger version or the locale revision moves. The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for.
@@ -70,14 +46,10 @@ Saving writes staged fields through the client settings scope, which fences each ## Further Exploration -These pages cover the Plugins page, the settings base, the inventory tab, and the durable seams behind the forms. - -- [ui-plugin-manager](../ui-plugin-manager/README.md) — the sidebar page whose `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` slots host configuration pages. -- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.section` and the settings scope. -- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only inventory the section shows. -- [settings](../../settings/README.md) — the durable user-settings seam and its file provider. -- [credentials](../../credentials/README.md) — the credential-reference seam secret fields write through. -- [ui-settings-general](../ui-settings-general/README.md) — the settings shell hosting the section. +- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only inventory tab. +- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.section`. +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page where official plugins are configured. +- [ui-settings-shell](../ui-settings-shell/README.md), [ui-settings-agent-loop](../ui-settings-agent-loop/README.md), [ui-settings-subagent](../ui-settings-subagent/README.md), [ui-settings-web-search](../ui-settings-web-search/README.md) — the official configuration pages, one companion package each. ----- @@ -94,13 +66,8 @@ None; this package neither assembles nor sends a provider request. - -These limits define which plugins get a page and how fresh the group is; they are current package constraints. - -- **Only host-plane plugins have a page** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all, so this package registers nothing for it. Editing those values remains the preset editor's job. -- **A page still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `../../../packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. -- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the mirror's read joins the Official group on the next document commit or reconnect. -- **The shell page follows the composed executor** — the POSIX and PowerShell executor families share the `shell` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the page edits the same two fields on both. +- **The section has no tab of its own** — it renders its empty line until a feature plugin registers one; the shell cannot fill the section alone. +- **Runtime invariant:** No companion is published. The section owns no relationship beyond the slot ledger it projects. ### Dev Note @@ -111,5 +78,3 @@ These limits define which plugins get a page and how fresh the group is; they ar None. - -**Runtime invariant:** No companion is published. This is a browser-side settings surface whose node half owns no event stream or mutable runtime data; the layering and write refusals are Host contracts covered by the owning plugins and the api-proxy. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 649c5c5121..d22a9ec3f1 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端的「内置插件」设置分区,以及注册进插件页的官方插件配置页。" +description: "dsh Web 客户端的「内置插件」设置分区:设置导航项与供功能插件注册标签页的标签行。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用**内置插件**设置分区查看本部署随附的插件;使用侧栏插件页的**官方**分组配置开放了设置的宿主平面插件。每个配置页标明用户覆盖过哪些值,允许把它们重置为部署默认值,在本地保留修改直到保存,离开页面即丢弃。如果配置在页面加载后发生变化,保存会被拒绝,而不会覆盖较新的值。 +使用**内置插件**设置分区查看本部署随附的插件。该分区只是一个壳:它拥有导航项和标签行,里面的每个标签页都由其他插件注册——只读清单注册了一个。配置内置插件在侧栏的插件页上进行,每个官方插件自己的伴生包把页面注册到那里。 ## 目录 @@ -25,25 +25,9 @@ kind: "package-reference" ## 使用本包 -打开设置中的**内置插件**查看只读的插件列表;它由 [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md) 作为分区唯一的标签页贡献,直接显示为页面本身。要配置宿主平面插件,在侧栏选择**插件**:官方分组为本部署组装的每个插件列出一张卡片,顺序依次为 shell 执行器(`shell`)、agent loop 的工具调用并行度(`agent-loop`)、Subagent 委派限制和模型选择(`subagent` 与 `subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`),点开卡片就是该插件带表单的页面。 +在设置里打开**内置插件**。[ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md) 把清单作为分区唯一的标签页贡献进来,直接显示为页面本身;注册第二个标签页后这一行就变成标签行。组合里没有任何标签页贡献的部署会显示分区的空提示。 -### 这里会出现什么 - -每个页面在 Host 服务其 settings 命名空间期间注册进插件页的 `plugins.item` slot,因此没有组装该插件的部署不会留下它的任何痕迹;Host 开始或停止服务某个命名空间时,其页面会在下一次 settings 文档提交或重连时加入或撤下。卡片上的一句话简介与页面上的表单是同一个条目按插件页索取的两种视图渲染出来的。 - -### 编辑与保存 - -页面暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存。离开页面即丢弃草稿,没有放弃控件。保存失败时页面保持原样、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。值是否被接受,唯一的裁判是 Host。 - -**Subagent** 卡片将委派限制与模型选择放在同一个页面中,共用一个保存按钮。**最大递归深度**和 **Subagent 并行数量上限**并排呈现,窄屏时上下排列。信息按钮按需展开两行深度示例表和共享计数规则;校验错误仍直接显示在输入框下。深度保留工具显式覆盖。总数统计所有递归层级中存活的可续接 Subagent,包含等待中的 Subagent,不计入主 Agent、一次性运行和外部提供方。保存后用于后续委派;调低上限不会停止已有 Subagent。 - -模型选择分区会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。 - -保存 Subagent 卡片时会校验两个分区,并通过各自原有命名空间写入草稿。这些写入相互独立:若其中一项失败,页面保持打开并保留该草稿,重试仅写入剩余草稿。只要部署提供任一命名空间,页面就会出现,且仅显示可用分区。 - -### secret 角色字段 - -密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。 +要贡献一个标签页,带 `id`、`order` 和本地化的 `label` 注册进 `settings.plugins.tab`;分区按序渲染条目,标签页在首次被选中时挂载。功能文案留在注册方自己的字典里。 ----- @@ -53,15 +37,7 @@ kind: "package-reference"
实现细节——点击展开 -本包是一条注册规则加一条写入路径:每个页面在其命名空间被服务期间注册;保存经由客户端 settings scope。 - -### 注册规则 - -本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页;只有一个贡献时它直接显示为页面本身,某个标签页首次被选择后会保持挂载,因此搜索与清单快照在切换时不会丢失。配置页是 `plugins.item` 注册项,每个命名空间一个:共享的 settings 镜像表明 Host 服务该命名空间时通过 `ctx.slots.inject` 注册,停止服务时销毁;注册顺序就是页面顺序,而不是 Host 的描述顺序——后者跟随插件激活,可能在两次启动之间变化。页面拥有自己的控件与文案;插件页负责画标题、图标与面包屑。 - -### 写入路径 - -保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;页面会在转发来的 `credentials/reference-updated` 事件到来时重读它所关注的引用。 +分区声明 `settings.plugins.tab`,一个根级 list slot,其标签成为有序的标签页;只有一个贡献时直接渲染为页面本身,标签页在首次被选中后保持挂载,搜索词和清单快照因此在切换间不丢失。分区的 `inject` 把 slot 账本投影成按序排列、标签随当前语言的行,在账本版本或语言修订变化前保持缓存。宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。
@@ -70,46 +46,35 @@ kind: "package-reference" ## 进一步探索 -以下页面覆盖插件页、设置底座、清单标签页与表单背后的持久化 seam。 - -- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——侧栏插件页,其 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` slot 承载配置页。 -- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.section` 与 settings scope 的领域底座。 -- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——分区显示的只读插件列表。 -- [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。 -- [credentials](../../credentials/README.zh.md)——secret 字段写入所经的凭据引用 seam。 -- [ui-settings-general](../ui-settings-general/README.zh.md)——承载本分区的设置外壳。 +- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——只读清单标签页。 +- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.section` 的领域基座。 +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——配置官方插件的插件页。 +- [ui-settings-shell](../ui-settings-shell/README.zh.md)、[ui-settings-agent-loop](../ui-settings-agent-loop/README.zh.md)、[ui-settings-subagent](../ui-settings-subagent/README.zh.md)、[ui-settings-web-search](../ui-settings-web-search/README.zh.md)——官方配置页,每个一个伴生包。 ----- ## 模型体验 -无。该包是浏览器端设置界面,不注册任何面向模型的接口。 +无,本包是浏览器侧的设置界面,不注册任何模型面。 -#### KV Cache 影响 +#### KV 缓存影响 -无;该包既不组装也不发送提供方请求。 +无;本包既不组装也不发送提供方请求。 ## 已知限制与延期工作 - -这些限制定义哪些插件会有页面、分组有多新鲜;它们是当前包约束。 - -- **只有宿主平面的插件有页面**:由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间,因此本包不会为它注册任何东西。编辑那些值仍是 preset 编辑器的职责。 -- **页面仍然需要一份浏览器 bundle**:浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `../../../packages/client/tsdown.client.ts`,并非已发布的包,因此本仓库之外的插件得自行复刻该构建。 -- **被服务的命名空间只在两种信号上重读**:协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在镜像读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入官方分组。 -- **shell 页面跟随被组装的执行器**:POSIX 与 PowerShell 两个执行器家族共用 `shell` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管页面在两者下编辑的都是同样两个字段。 +- **分区没有自己的标签页**——在功能插件注册标签页之前它只显示空提示;壳自己填不满分区。 +- **运行时不变量:**不发布伴生。分区除了投影 slot 账本之外不拥有任何关系。 ### 开发备注
-维护者的工作上下文——点击展开 +维护者工作上下文——点击展开 无。
- -**运行时不变式:** 不发布伴生入口。这是浏览器端设置界面,node half 不持有事件流或可变运行时数据;分层与写入拒绝是 Host 约定,由相应插件和 api-proxy 覆盖。 diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 0639c80393..e1346f298b 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", - "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", + "description": "Built-in plugins settings section for the dsh web client: the Settings navigation entry and the tab chrome feature-owned tabs register into", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -29,8 +29,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-client-ui-settings" ], "platform": "web" } @@ -45,17 +44,12 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@types/react": "~18.3.1", - "clsx": "^2.0.0", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts b/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts deleted file mode 100644 index 3ad8c6fefe..0000000000 --- a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** The shell card's staged form over the `bash` settings namespace. */ - -import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' - -/** - * Namespace of the shell capability. Spelled here rather than imported: a - * client package must not depend on a Host package, and the executor families - * that own it spell the same value. - */ -export const SHELL_NS = 'shell' - -/** The shell fields this card edits — a subset of the served schema by design. */ -export interface BashSettings { - /** Foreground command timeout in milliseconds. */ - timeoutMs?: number - /** Per-stream in-memory output cap in bytes. */ - maxOutputBytes?: number -} - -/** What the shell card renders. */ -export interface BashCardState extends CardShell { - /** Command timeout in milliseconds. */ - timeoutMs: CardFieldState - /** Per-stream output cap in bytes. */ - maxOutputBytes: CardFieldState -} - -/** The registration-side face the shell card's slot entry injects. */ -export interface BashCardFace extends CardActions { - hooks: { - /** Card snapshot bound by the renderer as useBashCard. */ - bashCard: SnapshotStore - } -} - -/** Bridges the `bash` scope onto the shell card's staged form. */ -export class BashCardController { - private readonly form: CardForm - private readonly store: SnapshotStore - - /** @param scope - the bound settings scope for the `bash` namespace. */ - constructor(scope: SettingsScope) { - this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')]) - this.store = this.form.bind(() => this.projection()) - } - - private projection(): BashCardState { - return { - ...this.form.shell(), - timeoutMs: this.form.field('timeoutMs'), - maxOutputBytes: this.form.field('maxOutputBytes'), - } - } - - /** - * Build the face the card's slot registration injects. - * @returns the card's snapshot and its form actions. - */ - inject(): BashCardFace { - return { hooks: { bashCard: this.store }, ...this.form.actions() } - } -} diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 7eccded6d6..1a4f2feff0 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -1,152 +1,41 @@ /** - * Built-in plugins settings surface and the official plugin configuration - * pages, browser half. The Settings section is the shell around the + * Built-in plugins settings section, browser half: the shell around the * feature-owned tabs registered into `settings.plugins.tab` (the read-only - * inventory ships one); the configuration pages this package ships register - * into the Plugins page's `plugins.item` slot, for the host-plane namespaces - * the deployment exposes, and appear in the page's Official group. Each form - * binds its namespace through the client settings scope, which keeps the - * pages unaware of one another and of the section. + * inventory ships one). The configuration pages of the host-plane plugins + * live in their own companion packages, which register into the Plugins + * page; this section owns the Settings navigation entry and the tab chrome + * only. */ // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry) -// and the ctx.settingsScope Context merge. Cross-plugin collaboration goes -// through the service, never a value import (client bundle purity gate). +// Type-only: the settings shell's SlotMap merge (the 'settings.section' +// entry). Cross-plugin collaboration goes through slots, never a value import +// (client bundle purity gate). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). -import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: the ctx.remote Context merge and the forwarded-event key face. -import type {} from '@deepseek-ai/dsh-api-remotes/client' -import { AgentLoopCard } from './AgentLoopCard.tsx' -import { BashCard } from './BashCard.tsx' import { PluginsSettingsSection } from './PluginsSettingsSection.tsx' import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx' -import { SubagentCard } from './SubagentCard.tsx' -import { subagentCardFace } from './subagent-card-controller.ts' -import { SubagentLimitsCardController } from './subagent-limits-card-controller.ts' -import { WebSearchCard } from './WebSearchCard.tsx' -import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' -import { SHELL_NS, BashCardController } from './bash-card-controller.ts' -import { - SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, -} from './subagent-model-selection-card-controller.ts' -import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' import { en, zh } from './locales.ts' export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx' -export type { PluginConfigFormProps } from './PluginConfigForm.tsx' -export type { FieldProps } from './fields.tsx' -export type { - CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell, -} from './card-form.ts' -export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts' -export type { BashCardFace, BashCardState } from './bash-card-controller.ts' -export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts' /** Dictionary namespace owned by this plugin. */ const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ -export const inject = [ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', -] +export const inject = ['slots', 'locale'] /** - * Mount the built-in plugins section and the configuration pages this package ships. + * Mount the built-in plugins section. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries') - const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS })) - const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) - const webSearch = new WebSearchCardController( - ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx) - const subagentLimits = new SubagentLimitsCardController(ctx.settingsScope.bind({ namespace: 'subagent' })) - const subagentModelSelection = new SubagentModelSelectionCardController( - ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), - ctx, - ) - const subagentLimitsFace = subagentLimits.inject() - const subagentModelsFace = subagentModelSelection.inject() - - // The credential a page reports is not part of any settings section, so its - // scope publishes nothing when one is written. This is the only signal that - // a key written on another surface reached the Host. - ctx.effect( - () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), - 'ui-settings-plugins: credential invalidations', - ) - ctx.effect( - () => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }), - 'ui-settings-plugins: subagent adapter invalidations', - ) - ctx.effect( - () => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }), - 'ui-settings-plugins: subagent settings invalidations', - ) - ctx.effect( - () => ctx.on('connection/reset', () => { subagentModelSelection.resetConnection() }), - 'ui-settings-plugins: subagent connection generation', - ) - ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') - - // Configuration pages register while the Host serves their namespaces. - // A deployment without those plugins shows no trace of them. Card registration order is the page order, not the - // Host's description order, which follows plugin activation and can change - // between boots. - const pages: ReadonlyArray () => void]> = [ - [[SHELL_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ - name: 'plugins.item', id: 'bash', order: 10, label: () => t('bashTitle'), locale: NS, inject: () => bash.inject(), - }, BashCard))], - [[AGENT_LOOP_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ - name: 'plugins.item', id: 'agent-loop', order: 20, label: () => t('agentLoopTitle'), locale: NS, inject: () => agentLoop.inject(), - }, AgentLoopCard))], - [['subagent', SUBAGENT_MODEL_SELECTION_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ - name: 'plugins.item', - id: 'subagent', - order: 30, - label: () => t('subagentTitle'), - locale: NS, - inject: () => subagentCardFace(subagentLimitsFace, subagentModelsFace), - }, SubagentCard))], - [[WEB_SEARCH_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ - name: 'plugins.item', id: 'web-search', order: 40, label: () => t('webSearchTitle'), locale: NS, inject: () => webSearch.inject(), - }, WebSearchCard))], - ] - // The shared SettingsScope mirror updates after document commits and reconnects. - const describeFace = ctx.settingsScope.describe() - ctx.effect(() => { - const registered = new Map void>() - const sync = (): void => { - const served = new Set(describeFace.getSnapshot().view?.namespaces.map(view => view.ns) ?? []) - for (const [namespaces, register] of pages) { - const namespace = namespaces[0] - const available = namespaces.some(namespace => served.has(namespace)) - const off = registered.get(namespace) - if (available && off === undefined) registered.set(namespace, register()) - else if (!available && off !== undefined) { - off() - registered.delete(namespace) - } - } - } - const unsubscribe = describeFace.subscribe(sync) - void describeFace.ensure() - sync() - return () => { - unsubscribe() - for (const off of registered.values()) off() - registered.clear() - } - }, 'ui-settings-plugins: configuration pages') - let tabsVersion = -1 let tabsRevision = -1 let tabs: readonly PluginsSettingsTabEntry[] = [] diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index e45dbb5cd0..0e82c56625 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -1,30 +1,7 @@ -/** Locale bundles for the built-in plugins settings section and the plugin configuration pages. */ +/** Locale bundles for the built-in plugins settings section. */ -/** Locale keys these surfaces render. */ -export type PluginsSettingsLocaleKey = - | 'nav' | 'title' | 'intro' | 'tabs' | 'empty' - | 'overridden' | 'reset' | 'readOnly' | 'unavailable' - | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' - | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint' - | 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint' - | 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint' - | 'webSearchTitle' | 'webSearchDescription' - | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' - | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' - | 'subagentTitle' | 'subagentDescription' | 'subagentLimitsTitle' - | 'subagentMaxDepth' - | 'subagentDepthHelpLabel' | 'subagentDepthHelp' - | 'subagentDepthZero' | 'subagentDepthOne' | 'subagentDepthOverride' - | 'subagentMaxActive' - | 'subagentCapacityHelpLabel' | 'subagentCapacityHelp' - | 'subagentDepthInvalid' - | 'subagentCapacityInvalid' - | 'subagentModelSelectionTitle' - | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' - | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' - | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' - | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' - | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff' +/** Locale keys the section renders. */ +export type PluginsSettingsLocaleKey = 'nav' | 'title' | 'intro' | 'tabs' | 'empty' /** English copy. */ export const en: Record = { @@ -33,62 +10,6 @@ export const en: Record = { intro: 'Inspect the plugins this deployment ships.', tabs: 'Plugin views', empty: 'This deployment exposes no plugin views.', - overridden: 'Overridden', - reset: 'Reset to default', - readOnly: 'This deployment stores settings read-only.', - unavailable: 'This plugin is not loaded, so it cannot be configured right now.', - save: 'Save', - saving: 'Saving…', - saveFailed: 'The deployment did not accept these values; they were left for you to correct.', - invalidNumber: 'Enter a number, or leave blank to use the default.', - bashTitle: 'Shell', - bashDescription: 'Limits every command the agent runs.', - bashTimeoutMs: 'Command timeout (ms)', - bashTimeoutMsHint: 'How long one command may run before it is terminated.', - bashMaxOutputBytes: 'Output cap per stream (bytes)', - bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.', - agentLoopTitle: 'Agent loop', - agentLoopDescription: 'How the agent dispatches tool calls.', - agentLoopMaxParallel: 'Parallel tool calls', - agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.', - webSearchTitle: 'Web search', - webSearchDescription: 'The DeepSeek search provider.', - webSearchApiKey: 'API key', - webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', - webSearchApiKeySet: 'A key is configured.', - webSearchApiKeyUnset: 'No key is configured; search is unavailable until one is.', - webSearchBaseUrl: 'Endpoint', - webSearchBaseUrlHint: 'Leave blank to use the provider default.', - webSearchMaxUses: 'Max searches per request', - webSearchMaxUsesHint: 'How many times one request may search before it must answer.', - subagentTitle: 'Subagent', - subagentDescription: 'Set Subagent recursion depth, count, and models.', - subagentLimitsTitle: 'Limits', - subagentMaxDepth: 'Maximum recursion depth', - subagentDepthHelpLabel: 'About maximum recursion depth', - subagentDepthHelp: 'Limits how many levels of Subagents an Agent can create.', - subagentDepthZero: 'Disable Subagents', - subagentDepthOne: 'Only the main Agent can create Subagents', - subagentDepthOverride: 'If a tool defines its own maximum recursion depth, that setting takes precedence.', - subagentMaxActive: 'Subagent parallelism limit', - subagentCapacityHelpLabel: 'About the Subagent parallelism limit', - subagentCapacityHelp: 'Total live Subagents under the same main Agent, across all recursion levels. The main Agent is excluded. New start requests are rejected when the limit is reached.', - subagentDepthInvalid: 'Enter a whole number of 0 or more.', - subagentCapacityInvalid: 'Enter a whole number of 1 or more.', - subagentModelSelectionTitle: 'Model selection', - subagentModelSelectionToggle: 'Allow agents to choose models for Subagents', - subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each Subagent from the authorized models below. Applies only to new sessions.', - subagentModelSelectionAllowed: 'Models agents may choose', - subagentModelSelectionLoading: 'Loading models…', - subagentModelSelectionLoadFailed: 'Models could not be loaded.', - subagentModelSelectionRetry: 'Retry', - subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.', - subagentModelSelectionUnavailable: 'Currently unavailable', - subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', - subagentModelSelectionEmpty: 'No model provider currently advertises a model.', - subagentModelSelectionRequired: 'Select at least one model before saving.', - subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.', - subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', } /** Simplified Chinese copy. */ @@ -98,60 +19,4 @@ export const zh: Record = { intro: '查看内置部署的插件列表', tabs: '插件视图', empty: '本部署没有开放任何插件视图。', - overridden: '已覆盖', - reset: '恢复默认', - readOnly: '本部署的设置为只读。', - unavailable: '该插件当前未加载,暂时无法配置。', - save: '保存', - saving: '保存中…', - saveFailed: '本部署没有接受这些值,已保留供你修改。', - invalidNumber: '请填数字;留空表示使用默认值。', - bashTitle: '终端', - bashDescription: '限制 agent 运行的每一条命令。', - bashTimeoutMs: '命令超时(毫秒)', - bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。', - bashMaxOutputBytes: '单流输出上限(字节)', - bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。', - agentLoopTitle: 'Agent 循环', - agentLoopDescription: 'Agent 如何派发工具调用。', - agentLoopMaxParallel: '并行工具调用数', - agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。', - webSearchTitle: '网页搜索', - webSearchDescription: 'DeepSeek 搜索提供方。', - webSearchApiKey: 'API Key', - webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。', - webSearchApiKeySet: '已配置密钥。', - webSearchApiKeyUnset: '未配置密钥;配置之前搜索不可用。', - webSearchBaseUrl: '接口地址', - webSearchBaseUrlHint: '留空则使用提供方默认地址。', - webSearchMaxUses: '单次请求最多搜索次数', - webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', - subagentTitle: 'Subagent', - subagentDescription: '设置 Subagent 的递归层级、数量和模型。', - subagentLimitsTitle: '运行限制', - subagentMaxDepth: '最大递归深度', - subagentDepthHelpLabel: '最大递归深度说明', - subagentDepthHelp: '限制 Agent 创建 Subagent 的递归层级。', - subagentDepthZero: '禁用 Subagent', - subagentDepthOne: '仅允许主 Agent 创建 Subagent', - subagentDepthOverride: '如果某个工具单独设置了最大递归深度,以该工具的设置为准。', - subagentMaxActive: 'Subagent 并行数量上限', - subagentCapacityHelpLabel: 'Subagent 并行数量上限说明', - subagentCapacityHelp: '同一主 Agent 下,所有递归层级同时存活的 Subagent 总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', - subagentDepthInvalid: '请输入不小于 0 的整数。', - subagentCapacityInvalid: '请输入不小于 1 的整数。', - subagentModelSelectionTitle: '模型选择', - subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型', - subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。', - subagentModelSelectionAllowed: 'Agent 可选择的模型', - subagentModelSelectionLoading: '正在加载模型…', - subagentModelSelectionLoadFailed: '无法加载模型。', - subagentModelSelectionRetry: '重试', - subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。', - subagentModelSelectionUnavailable: '当前不可用', - subagentModelSelectionUnavailableGroup: '已保存但当前不可用', - subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', - subagentModelSelectionRequired: '保存前请至少选择一个模型。', - subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。', - subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', } diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 8b24c35968..7581245ddb 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -1,68 +1,32 @@ /** What the browser half registers, and that it all leaves with the fiber. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { PluginsSettingsSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' -import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' import { apply as hostApply } from '../src/index.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. -/** - * @param served - namespaces the Host describes; omitted answers a failed read, - * which is what most of these specs want (no card has anything to render). - */ -async function bench(served?: string[]) { +async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - const describeCredentials = vi.fn(() => Promise.resolve({ - ok: false, error: new RemoteError('gateway/internal', 'no provider', {}), - })) - const models = vi.fn(() => Promise.resolve({ - ok: true as const, value: { groups: [], failures: [] }, - })) - const describeSettings = vi.fn(() => Promise.resolve(served === undefined - ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } - : { - ok: true, - value: { - writable: true, - hasDocument: true, - namespaces: served.map(ns => ({ - ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0, - })), - }, - })) - const remote = new TestRemote(ctx, { - credentials: { describe: describeCredentials, set: vi.fn() }, - session: { modelCatalog: models }, - settings: { describe: describeSettings }, - }) - await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { - ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote, - } + return { ctx, slots: ctx.get('slots') as SlotRegistry } } -/** The Settings shell's section slot and the Plugins page's item slot, as the two owners declare them. */ +/** The Settings shell's section slot, as its owner declares it. */ function declareRoot(slots: SlotRegistry): () => void { return slots.register({ name: 'root', - children: { - 'settings.section': { kind: 'list', scope: 'root' }, - 'plugins.item': { kind: 'list', scope: 'root' }, - }, + children: { 'settings.section': { kind: 'list', scope: 'root' } }, } as never, () => null) } @@ -72,9 +36,7 @@ describe('ui-settings-plugins apply', () => { }) it('declares the services it uses', () => { - expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', - ]) + expect(inject).toEqual(['slots', 'locale']) }) it('registers one Built-in plugins section and declares its tab slot, contributing no tab of its own', async () => { @@ -91,8 +53,8 @@ describe('ui-settings-plugins apply', () => { expect(slots.entries('settings.plugins.tab')).toHaveLength(0) }) - it('injects a live tab projection and one business face per configuration page', async () => { - const { ctx, slots } = await bench(['shell', 'agent-loop', 'subagent', 'subagent-model-selection', 'web-search-deepseek']) + it('injects a live tab projection ordered by the contributions', async () => { + const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() @@ -102,8 +64,7 @@ describe('ui-settings-plugins apply', () => { expect(initialTabs).toEqual([]) expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs) - const listener = vi.fn() - const unsubscribe = sectionFace.hooks.tabs.subscribe(listener) + const unsubscribe = sectionFace.hooks.tabs.subscribe(vi.fn()) slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null) slots.register({ name: 'settings.plugins.tab', id: 'first', order: -1 } as never, () => null) // Tabs follow their contribution's order, whatever order they registered in. @@ -112,151 +73,6 @@ describe('ui-settings-plugins apply', () => { { id: 'plain', order: 0, label: '' }, ]) unsubscribe() - - await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(4) }) - for (const entry of slots.entries('plugins.item')) { - const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } - expect(Object.keys(face.hooks)).toHaveLength(entry.options.id === 'subagent' ? 2 : 1) - } - }) - - it('registers one configuration page per served namespace, in its own order, titled in the active locale', async () => { - const { ctx, slots } = await bench(['web-search-deepseek', 'shell', 'agent-loop', 'subagent-model-selection']) - declareRoot(slots) - - await ctx.plugin({ inject: [...inject], apply }).await() - - await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(4) }) - const entries = slots.entries('plugins.item') - expect(entries.map(entry => entry.options.id)).toEqual(['bash', 'agent-loop', 'subagent', 'web-search']) - expect(entries.map(entry => resolveSlotLabel(entry.options.label))).toEqual(['终端', 'Agent 循环', 'Subagent', '网页搜索']) - expect(entries.every(entry => entry.locale === 'settings.plugins')).toBe(true) - }) - - it.each([ - ['subagent'], - ['subagent-model-selection'], - ['subagent', 'subagent-model-selection'], - ])('keeps one Subagent page while either namespace is served: %j', async (...served) => { - const { ctx, slots, describeSettings, remote } = await bench(served) - onTestFinished(() => ctx.fiber.dispose()) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { - expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['subagent']) - }) - const entry = slots.entries('plugins.item')[0] - for (const namespaces of [['subagent-model-selection'], ['subagent'], []]) { - describeSettings.mockResolvedValue({ - ok: true, - value: { - writable: true, hasDocument: true, - namespaces: namespaces.map(ns => ({ ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 1 })), - }, - }) - remote.emit('settings/document-updated', ['subagent', 1]) - await vi.waitFor(() => { - expect(ctx.settingsScope.describe().getSnapshot().view?.namespaces.map(view => view.ns)).toEqual(namespaces) - expect(slots.entries('plugins.item')).toEqual(namespaces.length > 0 ? [entry] : []) - }) - } - }) - - it('registers the pages of the served namespaces only, and withdraws one the Host stops serving', async () => { - // ui-theme is served but belongs to another surface, and a deployment - // composing no PowerShell/POSIX executor serves no `bash` at all. - const { ctx, slots, describeSettings, remote } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek']) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - - await vi.waitFor(() => { - expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['agent-loop', 'web-search']) - }) - - describeSettings.mockResolvedValue({ - ok: true, - value: { - writable: true, - hasDocument: true, - namespaces: [{ ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1 }], - }, - }) - remote.emit('settings/document-updated', ['web-search-deepseek', 1]) - - await vi.waitFor(() => { - expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['agent-loop']) - }) - }) - - it('re-reads the served namespaces when the Host commits a settings document', async () => { - // Which namespaces the Host serves is a registration fact the wire never - // announces on its own, so the tab rides the invalidation that can - // accompany a changed composition. - const { ctx, slots, describeSettings, remote } = await bench(['bash']) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) - describeSettings.mockClear() - - remote.emit('settings/document-updated', ['bash', 1]) - - await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) - }) - - it('re-reads the served namespaces after a reconnect', async () => { - const { ctx, slots, describeSettings } = await bench(['bash']) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) - describeSettings.mockClear() - - ctx.emit('connection/reset') - - await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) - }) - - it('re-reads the credential when the Host reports the watched reference changed', async () => { - const { ctx, slots, describeCredentials, remote } = await bench() - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) - describeCredentials.mockClear() - - // A key written on another surface changes no settings section, so this - // event is the only thing that reaches the card. - remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) - - await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) - }) - - it('refreshes the subagent catalog after model inputs change or the connection resets', async () => { - const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') - const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection') - const { ctx, slots, remote } = await bench(['subagent-model-selection']) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - refresh.mockClear() - reset.mockClear() - - remote.emit('llm/adapters-updated', []) - expect(refresh).toHaveBeenCalledTimes(1) - remote.emit('settings/document-updated', ['llm-deepseek', 1]) - expect(refresh).toHaveBeenCalledTimes(2) - ctx.emit('connection/reset') - expect(reset).toHaveBeenCalledTimes(1) - }) - - it('ignores a credential change for a reference no card watches', async () => { - const { ctx, slots, describeCredentials, remote } = await bench() - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) - describeCredentials.mockClear() - - remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY']) - await Promise.resolve() - - expect(describeCredentials).not.toHaveBeenCalled() }) it('registers into a declaration that arrives after apply', async () => { @@ -268,17 +84,15 @@ describe('ui-settings-plugins apply', () => { await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) }) }) - it('collapses every contribution on teardown', async () => { - const { ctx, slots } = await bench(['shell', 'agent-loop']) + it('collapses the section on teardown', async () => { + const { ctx, slots } = await bench() declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(2) }) + expect(slots.entries('settings.section')).toHaveLength(1) await fiber.dispose() expect(slots.entries('settings.section')).toHaveLength(0) - expect(slots.spec('settings.plugins.tab')).toBeUndefined() - expect(slots.entries('plugins.item')).toHaveLength(0) }) }) diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 069e19f0bb..0907213a17 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -1,47 +1,16 @@ // @vitest-environment jsdom +/** The Built-in plugins section: its heading, its tab row, and which tab is mounted. */ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { SubagentCard, type SubagentCardProps } from '../src/client/SubagentCard.tsx' -import type { SubagentLimitsCardState } from '../src/client/subagent-limits-card-controller.ts' -import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' -import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' -import { BashCard } from '../src/client/BashCard.tsx' -import type { BashCardProps } from '../src/client/BashCard.tsx' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx' import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx' -import { WebSearchCard } from '../src/client/WebSearchCard.tsx' -import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' -import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' -import type { BashCardState } from '../src/client/bash-card-controller.ts' -import type { CardFieldState, CardShell } from '../src/client/card-form.ts' -import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' -import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) const t = (key: keyof typeof en) => en[key] -const settled: CardShell = { - available: true, - writable: true, - dirty: false, - invalid: false, - saving: false, - failed: false, -} - -function field(text: string, rest: Partial = {}): CardFieldState { - return { text, overridden: false, invalid: false, ...rest } -} - -function cardActions() { - return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } -} - function renderSection(rows: readonly PluginsSettingsTabEntry[]) { const props = { t, @@ -53,70 +22,6 @@ function renderSection(rows: readonly PluginsSettingsTabEntry[]) { render() } -/** The Plugins page asks a configuration entry for its one-liner or its form; every page renders as `page` unless a test says otherwise. */ -type ConfigView = 'summary' | 'page' - -function renderBashCard(state: Partial = {}, view: ConfigView = 'page') { - const store = createSnapshotStore({ - ...settled, - timeoutMs: field('60000'), - maxOutputBytes: field('64000'), - ...state, - }) - const actions = cardActions() - const props = { ...actions, view, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps - render() - return { actions, store } -} - -function renderBash(state: Partial = {}) { - return renderBashCard(state).actions -} - -function renderSubagent( - limitState: Partial = {}, - modelState: Partial = {}, - view: ConfigView = 'page', -) { - const limits = createSnapshotStore({ - ...settled, - maxDepth: field('3'), - maxActiveSubagents: field('8'), - ...limitState, - }) - const models = createSnapshotStore({ - ...settled, - enabled: false, - candidates: [], - catalogStatus: 'idle', - catalogPartial: false, - conflicted: false, - ...modelState, - }) - const actions = { - editLimit: vi.fn(), - resetLimit: vi.fn(), - toggleEnabled: vi.fn(), - toggleModel: vi.fn(), - retryCatalog: vi.fn(), - save: vi.fn(), - discard: vi.fn(), - } - const props = { - ...actions, - view, - t, - useSubagentLimitsCard: bindSnapshotSelector(limits), - useSubagentModelSelectionCard: bindSnapshotSelector(models), - } as unknown as SubagentCardProps - render() - return { actions, limits, models } -} - -function renderSubagentModelSelection(state: Partial = {}, view: ConfigView = 'page') { - return renderSubagent({ available: false }, state, view).actions -} - describe('PluginsSettingsSection', () => { it('says so when no plugin contributed a tab', () => { renderSection([]) @@ -186,453 +91,3 @@ describe('PluginsSettingsSection', () => { expect(configurable.getAttribute('aria-selected')).toBe('true') }) }) - -describe('BashCard', () => { - it('renders its one-liner alone in the summary view', () => { - renderBashCard({}, 'summary') - - expect(document.body.textContent).toBe(en.bashDescription) - expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() - }) - - it('says the plugin is not loaded in place of its fields while its namespace is unavailable', () => { - renderBash({ available: false }) - - expect(screen.getByRole('status').textContent).toBe(en.unavailable) - expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() - }) - - it('shows its fields at once on its page, without a title of its own', () => { - renderBash() - - expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() - expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy() - expect(screen.queryByText(en.bashTitle)).toBeNull() - }) - - it('stages an edit instead of writing it', () => { - const actions = renderBash() - - fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } }) - - expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000') - expect(actions.save).not.toHaveBeenCalled() - }) - - it('offers the reset for an overridden field only', () => { - const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) }) - - // One badge and one reset: the output cap is still inherited. - expect(screen.getAllByText(en.overridden)).toHaveLength(1) - fireEvent.click(screen.getByRole('button', { name: en.reset })) - - expect(actions.resetField).toHaveBeenCalledWith('timeoutMs') - }) - - it('addresses each of its two fields separately', () => { - const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) }) - - fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } }) - fireEvent.click(screen.getByRole('button', { name: en.reset })) - - expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024') - expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes') - }) - - it('keeps the save inert until something is staged, and offers no discard', () => { - renderBash() - - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - expect(screen.getAllByRole('button')).toHaveLength(1) - }) - - it('writes the staged edits when saved', () => { - const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) }) - - fireEvent.click(screen.getByRole('button', { name: en.save })) - - expect(actions.save).toHaveBeenCalledOnce() - expect(actions.discard).not.toHaveBeenCalled() - }) - - it('drops the staged edits when it leaves the page', () => { - const actions = renderBash({ dirty: true }) - expect(actions.discard).not.toHaveBeenCalled() - - cleanup() - - expect(actions.discard).toHaveBeenCalledOnce() - }) - - it('blocks the save while a draft is invalid, and says why', () => { - renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) }) - - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - expect(screen.getByText(en.invalidNumber)).toBeTruthy() - }) - - it('reports a save in flight and refuses another', () => { - renderBash({ dirty: true, saving: true }) - - expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) - }) - - it('reports a save the deployment did not accept and keeps the fields for correction', () => { - renderBash({ dirty: true, failed: true }) - - expect(screen.getByText(en.saveFailed)).toBeTruthy() - expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() - }) - - it('says the document is read-only and disables its controls', () => { - renderBash({ writable: false }) - - expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly) - expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true) - }) -}) - -describe('Subagent model selection fields', () => { - it('renders the default-off preference in its staged plugin card', () => { - const actions = renderSubagentModelSelection() - - const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(control.getAttribute('aria-checked')).toBe('false') - fireEvent.click(control) - - expect(actions.toggleEnabled).toHaveBeenCalledOnce() - }) - - it('groups available adapter candidates by provider', () => { - const actions = renderSubagentModelSelection({ - enabled: true, - candidates: [ - { - key: 'alpha\0fast', - provider: 'alpha', - model: 'fast', - providerName: 'Alpha API', - modelName: 'Fast', - available: true, - selected: true, - }, - { - key: 'alpha\0deep', - provider: 'alpha', - model: 'deep', - providerName: 'Alpha API', - modelName: 'Deep', - available: true, - selected: false, - }, - ], - catalogStatus: 'ready', - }) - - expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') - expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() - fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) - fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ })) - expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') - expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep') - }) - - it('renders directory progress, failures, unavailable routes, and validation', () => { - renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) - expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() - expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() - - cleanup() - const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) - fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) - expect(errorActions.retryCatalog).toHaveBeenCalledOnce() - - cleanup() - renderSubagentModelSelection({ - enabled: true, - catalogStatus: 'ready', - catalogPartial: true, - candidates: [{ - key: 'legacy\0old', - provider: 'legacy', - model: 'old', - providerName: 'legacy', - modelName: 'old', - available: false, - selected: true, - }], - }) - expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() - expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() - expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() - - cleanup() - renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) - expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() - }) - - it('distinguishes a stale draft from a rejected save', () => { - renderSubagentModelSelection({ dirty: true, conflicted: true }) - - expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() - expect(screen.queryByText(en.saveFailed)).toBeNull() - }) - - it('renders its one-liner in the summary view, says so when unavailable, and disables writes when read-only', () => { - renderSubagentModelSelection({}, 'summary') - expect(document.body.textContent).toBe(en.subagentDescription) - - cleanup() - renderSubagentModelSelection({ available: false }) - expect(screen.getByRole('status').textContent).toBe(en.unavailable) - - cleanup() - const actions = renderSubagentModelSelection({ writable: false }) - const control = screen.getByRole('switch') as HTMLButtonElement - expect(control.disabled).toBe(true) - fireEvent.click(control) - expect(actions.toggleEnabled).not.toHaveBeenCalled() - }) -}) - -describe('AgentLoopCard', () => { - it('renders its one-liner alone in the summary view', () => { - const store = createSnapshotStore({ ...settled, maxParallelToolCalls: field('10') }) - const props = { ...cardActions(), view: 'summary', t, useAgentLoopCard: bindSnapshotSelector(store) } as unknown as AgentLoopCardProps - render() - - expect(document.body.textContent).toBe(en.agentLoopDescription) - expect(screen.queryByLabelText(en.agentLoopMaxParallel)).toBeNull() - }) - - it('stages and saves the only field it owns', () => { - const store = createSnapshotStore({ - ...settled, - dirty: true, - maxParallelToolCalls: field('10'), - }) - const actions = cardActions() - const props = { - ...actions, - view: 'page', - t, - useAgentLoopCard: bindSnapshotSelector(store), - } as unknown as AgentLoopCardProps - render() - - fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } }) - fireEvent.click(screen.getByRole('button', { name: en.save })) - - expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2') - expect(actions.save).toHaveBeenCalledOnce() - }) - - it('stages a reset for the field it owns', () => { - const store = createSnapshotStore({ - ...settled, - maxParallelToolCalls: field('2', { overridden: true }), - }) - const actions = cardActions() - const props = { - ...actions, - view: 'page', - t, - useAgentLoopCard: bindSnapshotSelector(store), - } as unknown as AgentLoopCardProps - render() - - fireEvent.click(screen.getByRole('button', { name: en.reset })) - - expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls') - }) -}) - -describe('WebSearchCard', () => { - function renderWebSearch(state: Partial = {}) { - const store = createSnapshotStore({ - ...settled, - baseURL: field(''), - maxUses: field('5'), - apiKey: field(''), - apiKeyConfigured: false, - apiKeyWritable: true, - ...state, - }) - const actions = cardActions() - const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps - render() - return actions - } - - it('renders its one-liner alone in the summary view', () => { - const store = createSnapshotStore({ - ...settled, baseURL: field(''), maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true, - }) - const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps - render() - - expect(document.body.textContent).toBe(en.webSearchDescription) - expect(screen.queryByLabelText(en.webSearchApiKey)).toBeNull() - }) - - it('reports whether a key is configured without ever showing one', () => { - renderWebSearch({ apiKeyConfigured: true }) - - expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy() - expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password') - }) - - it('keeps the key control usable while the settings document is read-only', () => { - const actions = renderWebSearch({ writable: false }) - - const key = screen.getByLabelText(en.webSearchApiKey) - expect(key).toHaveProperty('disabled', false) - expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true) - - fireEvent.change(key, { target: { value: 'ds-secret' } }) - - expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') - }) - - it('disables the key control when the reference itself is not writable', () => { - // A key coming from the process environment: the settings document is - // writable, the credential is not. - renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) - - expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true) - expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false) - }) - - it('stages the endpoint, the search budget, and their resets', () => { - const actions = renderWebSearch({ - baseURL: field('https://search.test/v1', { overridden: true }), - maxUses: field('3', { overridden: true }), - }) - - fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } }) - fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } }) - const resets = screen.getAllByRole('button', { name: en.reset }) - expect(resets).toHaveLength(2) - for (const reset of resets) fireEvent.click(reset) - - expect(actions.edit.mock.calls).toEqual([ - ['baseURL', 'https://other.test'], - ['maxUses', '4'], - ]) - expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']]) - }) -}) - - -describe('SubagentCard', () => { - it('discards both drafts when leaving the page', () => { - const { actions } = renderSubagent({ dirty: true }, { dirty: true }) - cleanup() - expect(actions.discard).toHaveBeenCalledOnce() - }) - - it('renders limits without model selection when only limits are served', () => { - renderSubagent({}, { available: false }) - expect(screen.getByLabelText(en.subagentMaxDepth)).toBeTruthy() - expect(screen.queryByRole('switch')).toBeNull() - }) - - it('shows both sections on one page with one save footer', () => { - renderSubagent({ dirty: true }) - - expect(screen.getByRole('heading', { name: en.subagentLimitsTitle })).toBeTruthy() - expect(screen.getByRole('heading', { name: en.subagentModelSelectionTitle })).toBeTruthy() - expect(screen.getAllByRole('button', { name: en.save })).toHaveLength(1) - }) - - it('reveals field rules on demand without changing staged values', () => { - renderSubagent({ dirty: true, maxDepth: field('2') }) - const depthHelp = screen.getByRole('button', { name: en.subagentDepthHelpLabel }) - const capacityHelp = screen.getByRole('button', { name: en.subagentCapacityHelpLabel }) - expect(depthHelp.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText(en.subagentDepthHelp)).toBeNull() - expect(screen.queryByText(en.subagentCapacityHelp)).toBeNull() - - fireEvent.click(depthHelp) - expect(depthHelp.getAttribute('aria-expanded')).toBe('true') - expect(screen.getByRole('region', { name: en.subagentDepthHelpLabel })).toBeTruthy() - expect(screen.getByText(en.subagentDepthHelp)).toBeTruthy() - expect(screen.getByRole('table', { name: en.subagentDepthHelpLabel })).toBeTruthy() - expect(screen.getByRole('row', { name: `0 ${en.subagentDepthZero}` })).toBeTruthy() - expect(screen.getByRole('row', { name: `1 ${en.subagentDepthOne}` })).toBeTruthy() - expect(screen.getByText(en.subagentDepthOverride)).toBeTruthy() - fireEvent.click(capacityHelp) - expect(screen.getByText(en.subagentCapacityHelp)).toBeTruthy() - fireEvent.click(depthHelp) - expect(screen.queryByText(en.subagentDepthHelp)).toBeNull() - expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('value', '2') - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false) - }) - - it('keeps validation visible when the rules are collapsed and links it to the input', () => { - renderSubagent({ dirty: true, invalid: true, maxDepth: field('1.5', { invalid: true }) }) - const depth = screen.getByLabelText(en.subagentMaxDepth) - const messageId = depth.getAttribute('aria-describedby')! - expect(document.getElementById(messageId)?.textContent).toBe(en.subagentDepthInvalid) - expect(screen.queryByRole('region', { name: en.subagentDepthHelpLabel })).toBeNull() - }) - - it('edits and resets limits through the shared card', () => { - const { actions, limits } = renderSubagent({ - dirty: true, - maxDepth: field('3', { overridden: true }), - maxActiveSubagents: field('8', { overridden: true }), - }) - fireEvent.change(screen.getByLabelText(en.subagentMaxDepth), { target: { value: '2' } }) - fireEvent.change(screen.getByLabelText(en.subagentMaxActive), { target: { value: '12' } }) - expect(actions.editLimit.mock.calls).toEqual([['maxDepth', '2'], ['maxActiveSubagents', '12']]) - for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button) - expect(actions.resetLimit.mock.calls).toEqual([['maxDepth'], ['maxActiveSubagents']]) - act(() => { limits.set({ ...limits.getSnapshot(), writable: false }) }) - expect(screen.getByLabelText(en.subagentMaxActive)).toHaveProperty('disabled', true) - }) - - it('blocks saving both sections when a model selection is invalid or conflicted', () => { - const { models } = renderSubagent({ dirty: true }, { dirty: true, invalid: true }) - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - act(() => { models.set({ ...models.getSnapshot(), invalid: false, conflicted: true }) }) - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - }) - - it('locks both sections while either is saving and stays open after both settle', () => { - const { limits, models } = renderSubagent({ dirty: true }, { dirty: true }) - act(() => { - limits.set({ ...limits.getSnapshot(), saving: true }) - models.set({ ...models.getSnapshot(), saving: true }) - }) - expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('disabled', true) - expect(screen.getByRole('switch')).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) - act(() => { limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) }) - expect(screen.getByRole('switch')).toHaveProperty('disabled', true) - act(() => { models.set({ ...models.getSnapshot(), saving: false, dirty: false }) }) - expect(screen.getByRole('switch')).toHaveProperty('disabled', false) - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - }) - - it('keeps a rejected section open after the other section saves', () => { - const { limits, models } = renderSubagent({ dirty: true }, { dirty: true }) - act(() => { - limits.set({ ...limits.getSnapshot(), saving: true }) - models.set({ ...models.getSnapshot(), saving: true }) - }) - act(() => { - limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) - models.set({ ...models.getSnapshot(), saving: false, failed: true }) - }) - expect(screen.getByRole('switch')).toBeTruthy() - expect(screen.getByText(en.saveFailed)).toBeTruthy() - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false) - }) - - it('renders model-only deployments without limit controls', () => { - renderSubagent({ available: false }) - expect(screen.getByRole('switch')).toBeTruthy() - expect(screen.queryByLabelText(en.subagentMaxDepth)).toBeNull() - }) -}) diff --git a/packages/client/ui-settings-plugins/tsconfig.json b/packages/client/ui-settings-plugins/tsconfig.json index a43ca3698f..b30ef5452e 100644 --- a/packages/client/ui-settings-plugins/tsconfig.json +++ b/packages/client/ui-settings-plugins/tsconfig.json @@ -14,27 +14,15 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../store" - }, { "path": "../../test-support/client-runtime" }, - { - "path": "../ui-primitives" - }, { "path": "../ui-renderer" }, - { - "path": "../ui-plugin-manager" - }, { "path": "../ui-settings" }, - { - "path": "../../api/remotes/tsconfig.client.json" - }, { "path": "../ui-slots" } diff --git a/packages/client/ui-settings-shell/README.i18n.yaml b/packages/client/ui-settings-shell/README.i18n.yaml new file mode 100644 index 0000000000..deca0c7f4a --- /dev/null +++ b/packages/client/ui-settings-shell/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-settings-shell/README.md +README.md: f77cc0e5f49f426c6801f3832a65bc8214080775 +README.zh.md: 81b924fab3481a835fdc3df9771b169fb3b6c30a diff --git a/packages/client/ui-settings-shell/README.md b/packages/client/ui-settings-shell/README.md new file mode 100644 index 0000000000..f77cc0e5f4 --- /dev/null +++ b/packages/client/ui-settings-shell/README.md @@ -0,0 +1,78 @@ +--- +description: "The shell executor's settings page on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-shell + +English | [中文](README.zh.md) + +## Summary + +Open **Plugins** in the sidebar and select **Shell** in the Official group to set how long one command may run and how much of each output stream stays in memory. The page stages what is typed and writes it only on save, marks the values the user overrode, and offers to reset each back to the deployment's default. The page exists while the Host serves the `shell` namespace, so a deployment without a local shell executor shows no trace of it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The **Shell** card in the Official group opens the page. **Command timeout (ms)** ends a command that runs longer; **Output cap per stream (bytes)** spills output beyond it to a temporary file. Both render the effective value — the user's override over the composed default — and a field the user overrode carries an **Overridden** badge with **Reset to default** beside it. Nothing is written until **Save**; leaving the page drops the drafts, an empty field saves as a reset, and text that is not a number blocks the save and says so under the field. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `shell` namespace through `ctx.settingsScope`, keeps the staged form in `ShellCardController` over the shared `SettingsFormModel` of `ui-primitives`, and registers `ShellCard` into the Plugins page's `plugins.item` slot through `ctx.settingsScope.whileServed`, which registers while the Host serves the namespace and withdraws when it stops. The page's copy lives in this package's `settings.shell` dictionary; the `SettingsForm` frame takes its copy as props. + +
+ +----- + + +## Further Exploration + +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. +- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. +- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the page renders. +- [bash-local](../../shell/bash-local/README.md) — the executor that registers the `shell` namespace. + +----- + + +## Model Experience + +None, as the package is a browser-side settings surface that registers no model surface. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **The page follows the composed executor** — the POSIX and PowerShell executor families share the `shell` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) while the page edits the same two fields on both. +- **Runtime invariant:** No companion is published. The page holds no owned relationship of its own: what it shows derives from the settings mirror, and what it writes the Host validates. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-settings-shell/README.zh.md b/packages/client/ui-settings-shell/README.zh.md new file mode 100644 index 0000000000..81b924fab3 --- /dev/null +++ b/packages/client/ui-settings-shell/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "dsh Web 客户端插件页上的终端执行器设置页:shell 命名空间的命令超时与单流输出上限。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-shell + +[English](README.md) | 中文 + +## 概述 + +在侧栏打开**插件**,在官方分组里选择**终端**,即可设置单条命令允许运行多久、每条输出流在内存里保留多少。页面暂存输入、只在保存时写入,标明用户覆盖过的值,并允许把每个值重置回部署默认值。页面只在 Host 服务 `shell` 命名空间期间存在,没有本地终端执行器的部署看不到它。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +官方分组里的**终端**卡片打开这一页。**命令超时(毫秒)**终止运行更久的命令;**单流输出上限(字节)**把超出的输出转存到临时文件。两个字段都显示生效值,即用户覆盖值叠在组合默认值之上;用户覆盖过的字段带**已覆盖**标签,旁边是**恢复默认**。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿,清空字段并保存等于重置,填了不是数字的文本则保存被阻止,并在字段下说明原因。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.settingsScope` 绑定 `shell` 命名空间,用 `ui-primitives` 的共享 `SettingsFormModel` 在 `ShellCardController` 里维护暂存表单,并通过 `ctx.settingsScope.whileServed` 把 `ShellCard` 注册进插件页的 `plugins.item` slot:Host 服务该命名空间时注册,停止服务时注销。页面文案在本包的 `settings.shell` 字典里;`SettingsForm` 框架的文案以 props 传入。 + +
+ +----- + + +## 进一步探索 + +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 +- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 +- [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。 +- [bash-local](../../shell/bash-local/README.zh.md)——注册 `shell` 命名空间的执行器。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的设置界面,不注册任何模型面。 + +#### KV 缓存影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **页面跟随组合进来的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `shell` 命名空间,因为一台主机只组合其中一个,所以服务出来的 schema 因平台而异(PowerShell 多一个 `pwshPath`),而页面在两个平台上编辑的都是同样两个字段。 +- **运行时不变量:**不发布伴生。本页没有自己拥有的关系:它显示的内容派生自设置镜像,它写入的内容由 Host 校验。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-settings-shell/package.json b/packages/client/ui-settings-shell/package.json new file mode 100644 index 0000000000..ed5afb29c1 --- /dev/null +++ b/packages/client/ui-settings-shell/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-shell", + "description": "Settings page of the shell executor on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-shell" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugins/src/client/BashCard.tsx b/packages/client/ui-settings-shell/src/client/ShellCard.tsx similarity index 51% rename from packages/client/ui-settings-plugins/src/client/BashCard.tsx rename to packages/client/ui-settings-shell/src/client/ShellCard.tsx index 96f5791be8..16b8b3fe34 100644 --- a/packages/client/ui-settings-plugins/src/client/BashCard.tsx +++ b/packages/client/ui-settings-shell/src/client/ShellCard.tsx @@ -1,38 +1,33 @@ -/** The shell plugin's configuration page: the limits every command the agent runs is bound by. */ +/** The shell executor's settings page: the limits every command the agent runs is bound by. */ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import { SettingsForm, SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { ValueField } from './fields.tsx' -import { PluginConfigForm } from './PluginConfigForm.tsx' -import type { BashCardFace } from './bash-card-controller.ts' +import { formLabels } from './locales.ts' +import type { ShellCardFace } from './shell-card-controller.ts' /** Props the renderer binds for the shell page. */ -export type BashCardProps = +export type ShellCardProps = PropsRuntime<'plugins.item'> - & PropsLocale<'settings.plugins'> - & InjectFace + & PropsLocale<'settings.shell'> + & InjectFace /** - * Render the shell plugin's one-liner or its configuration form, as the Plugins page asks. + * Render the shell executor's one-liner or its settings form, as the Plugins page asks. * @param props - the view asked for, locale copy, the form snapshot, and its actions. * @returns the one-liner, or the form. */ -export function BashCard(props: BashCardProps) { +export function ShellCard(props: ShellCardProps) { const { t } = props - const state = props.useBashCard(snapshot => snapshot) - if (props.view === 'summary') return t('bashDescription') + const state = props.useShellCard(snapshot => snapshot) + if (props.view === 'summary') return t('description') const disabled = !state.writable return ( - - + { props.edit('timeoutMs', text) }} onReset={() => { props.resetField('timeoutMs') }} /> - { props.edit('maxOutputBytes', text) }} onReset={() => { props.resetField('maxOutputBytes') }} /> - + ) } diff --git a/packages/client/ui-settings-shell/src/client/index.ts b/packages/client/ui-settings-shell/src/client/index.ts new file mode 100644 index 0000000000..d9f975fb2b --- /dev/null +++ b/packages/client/ui-settings-shell/src/client/index.ts @@ -0,0 +1,50 @@ +/** + * The shell executor's settings page, browser half: the command timeout and + * the per-stream output cap over the `shell` namespace the executor + * registers. The page registers into the Plugins page's `plugins.item` slot + * while the Host serves that namespace, so a deployment without a local shell + * executor shows no trace of it. + */ + +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { ShellCard } from './ShellCard.tsx' +import { SHELL_NS, ShellCardController } from './shell-card-controller.ts' +import { en, zh, type ShellSettingsLocaleKey } from './locales.ts' + +export type { ShellCardProps } from './ShellCard.tsx' +export type { ShellCardFace, ShellCardState, ShellSettings } from './shell-card-controller.ts' +export type { ShellSettingsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Shell settings page copy. */ + 'settings.shell': ShellSettingsLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.shell' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'settingsScope'] + +/** + * Mount the shell settings page while the Host serves its namespace. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-shell: dictionaries') + const card = new ShellCardController(ctx.settingsScope.bind({ namespace: SHELL_NS })) + ctx.effect(() => ctx.settingsScope.whileServed([SHELL_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'shell', order: 10, label: () => t('title'), locale: NS, inject: () => card.inject(), + }, ShellCard))), 'ui-settings-shell: page') +} diff --git a/packages/client/ui-settings-shell/src/client/locales.ts b/packages/client/ui-settings-shell/src/client/locales.ts new file mode 100644 index 0000000000..b456046ca3 --- /dev/null +++ b/packages/client/ui-settings-shell/src/client/locales.ts @@ -0,0 +1,55 @@ +/** Locale bundles for the shell executor's settings page. */ + +import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Locale keys the page renders. */ +export type ShellSettingsLocaleKey = + | 'title' | 'description' + | 'timeoutMs' | 'timeoutMsHint' | 'maxOutputBytes' | 'maxOutputBytesHint' + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' + +/** English copy. */ +export const en: Record = { + title: 'Shell', + description: 'Limits every command the agent runs.', + timeoutMs: 'Command timeout (ms)', + timeoutMsHint: 'How long one command may run before it is terminated.', + maxOutputBytes: 'Output cap per stream (bytes)', + maxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.', + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + save: 'Save', + saving: 'Saving…', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + invalidNumber: 'Enter a number, or leave blank to use the default.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + title: '终端', + description: '限制 agent 运行的每一条命令。', + timeoutMs: '命令超时(毫秒)', + timeoutMsHint: '单条命令允许运行多久,超时即终止。', + maxOutputBytes: '单流输出上限(字节)', + maxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。', + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + unavailable: '该插件当前未加载,暂时无法配置。', + save: '保存', + saving: '保存中…', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + invalidNumber: '请填数字;留空表示使用默认值。', +} + +/** + * The form frame's copy, read from this page's dictionary. + * @param t - the page's locale reader. + * @returns the labels the shared settings form renders. + */ +export function formLabels(t: (key: ShellSettingsLocaleKey) => string): SettingsFormLabels { + return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') } +} diff --git a/packages/client/ui-settings-shell/src/client/shell-card-controller.ts b/packages/client/ui-settings-shell/src/client/shell-card-controller.ts new file mode 100644 index 0000000000..f5648931b6 --- /dev/null +++ b/packages/client/ui-settings-shell/src/client/shell-card-controller.ts @@ -0,0 +1,66 @@ +/** The shell page's staged form over the `shell` settings namespace. */ + +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { + SettingsFormModel, settingsNumberField, + type SettingsFieldState, type SettingsFormActions, type SettingsFormScope, type SettingsFormShell, +} from '@deepseek-ai/dsh-client-ui-primitives' + +/** + * Namespace of the shell capability. Spelled here rather than imported: a + * client package must not depend on a Host package, and the executor families + * that own it spell the same value. + */ +export const SHELL_NS = 'shell' + +/** The shell fields this page edits — a subset of the served schema by design. */ +export interface ShellSettings { + /** Foreground command timeout in milliseconds. */ + timeoutMs?: number + /** Per-stream in-memory output cap in bytes. */ + maxOutputBytes?: number +} + +/** What the shell page renders. */ +export interface ShellCardState extends SettingsFormShell { + /** Command timeout in milliseconds. */ + timeoutMs: SettingsFieldState + /** Per-stream output cap in bytes. */ + maxOutputBytes: SettingsFieldState +} + +/** The registration-side face the shell page's slot entry injects. */ +export interface ShellCardFace extends SettingsFormActions { + hooks: { + /** Page snapshot bound by the renderer as useShellCard. */ + shellCard: SnapshotStore + } +} + +/** Bridges the `shell` scope onto the page's staged form. */ +export class ShellCardController { + private readonly form: SettingsFormModel + private readonly store: SnapshotStore + + /** @param scope - the bound settings scope for the `shell` namespace. */ + constructor(scope: SettingsFormScope) { + this.form = new SettingsFormModel(scope, [settingsNumberField('timeoutMs'), settingsNumberField('maxOutputBytes')]) + this.store = this.form.bind(() => this.projection()) + } + + private projection(): ShellCardState { + return { + ...this.form.shell(), + timeoutMs: this.form.field('timeoutMs'), + maxOutputBytes: this.form.field('maxOutputBytes'), + } + } + + /** + * Build the face the page's slot registration injects. + * @returns the page's snapshot and its form actions. + */ + inject(): ShellCardFace { + return { hooks: { shellCard: this.store }, ...this.form.actions() } + } +} diff --git a/packages/client/ui-settings-shell/src/css-modules.d.ts b/packages/client/ui-settings-shell/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-settings-shell/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-settings-shell/src/index.ts b/packages/client/ui-settings-shell/src/index.ts new file mode 100644 index 0000000000..f8d853e9be --- /dev/null +++ b/packages/client/ui-settings-shell/src/index.ts @@ -0,0 +1,10 @@ +/** + * Shell settings page, node half. The empty apply exists so the plugin appears + * in the host cordis.yml / Loader; the browser half owns the page through + * exports["./client"], discovered from the package.json dsh.client + * declaration. The `shell` namespace the page edits is registered by the + * shell executor, so this package registers no namespace of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-shell/tests/apply.client.spec.ts b/packages/client/ui-settings-shell/tests/apply.client.spec.ts new file mode 100644 index 0000000000..3bc0b93161 --- /dev/null +++ b/packages/client/ui-settings-shell/tests/apply.client.spec.ts @@ -0,0 +1,113 @@ +/** What the browser half registers, when, and that it all leaves with the fiber. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject, NS } from '../src/client/index.ts' +import type { ShellCardFace } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' + +// These specs assert the shipped Chinese copy. The lane has no jsdom `window`, +// so browser-language detection never runs and a fresh LocaleRuntime opens on +// FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. + +/** One Host view of a served namespace. */ +function view(ns: string, revision = 0) { + return { ns, schema: {}, value: {}, applies: 'live', secrets: [], revision } +} + +/** + * @param served - namespaces the Host describes; omitted answers a failed read, + * which leaves the page nothing to register for. + */ +async function bench(served?: string[]) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + locale.setLocale('zh') + ctx.provide('locale', locale) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } + : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const remote = new TestRemote(ctx, { settings: { describe: describeSettings } }) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeSettings, remote } +} + +/** The Plugins page's item slot, as its owner declares it. */ +function declareRoot(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'plugins.item': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-shell apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'settingsScope']) + }) + + it('registers the shell page while the Host serves the namespace, titled in the active locale', async () => { + const { ctx, slots } = await bench(['shell', 'ui-theme']) + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + const entry = slots.entries('plugins.item')[0]! + expect(entry.options).toMatchObject({ id: 'shell', order: 10 }) + expect(resolveSlotLabel(entry.options.label)).toBe('终端') + expect(entry.locale).toBe(NS) + const face = (entry.inject as unknown as () => ShellCardFace)() + expect(Object.keys(face.hooks)).toEqual(['shellCard']) + expect(face.hooks.shellCard.getSnapshot()).toMatchObject({ available: false, dirty: false }) + }) + + it('registers nothing while the namespace is not served, and follows the Host when that changes', async () => { + // A deployment composing no POSIX or PowerShell executor serves no `shell` at all. + const { ctx, slots, describeSettings, remote } = await bench(['agent-loop']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + expect(slots.entries('plugins.item')).toHaveLength(0) + + describeSettings.mockResolvedValue({ + ok: true, value: { writable: true, hasDocument: true, namespaces: [view('shell', 1)] }, + }) + remote.emit('settings/document-updated', ['shell', 1]) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + describeSettings.mockResolvedValue({ ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }) + remote.emit('settings/document-updated', ['shell', 2]) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(0) }) + }) + + it('registers into a declaration that arrives after apply', async () => { + const { ctx, slots } = await bench(['shell']) + await ctx.plugin({ inject: [...inject], apply }).await() + + declareRoot(slots) + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + }) + + it('collapses the page on teardown', async () => { + const { ctx, slots } = await bench(['shell']) + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + await fiber.dispose() + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-settings-shell/tests/card.client.spec.tsx b/packages/client/ui-settings-shell/tests/card.client.spec.tsx new file mode 100644 index 0000000000..d789c28165 --- /dev/null +++ b/packages/client/ui-settings-shell/tests/card.client.spec.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +/** The shell page as the Plugins page renders it: its one-liner, its two fields, and what a save does. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' +import { ShellCard, type ShellCardProps } from '../src/client/ShellCard.tsx' +import type { ShellCardState } from '../src/client/shell-card-controller.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + +function field(text: string, rest: Partial = {}): SettingsFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +function renderCard(state: Partial = {}, view: 'summary' | 'page' = 'page') { + const store = createSnapshotStore({ + ...settled, timeoutMs: field('60000'), maxOutputBytes: field('64000'), ...state, + }) + const actions = { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } + const props = { ...actions, view, t, useShellCard: bindSnapshotSelector(store) } as unknown as ShellCardProps + render() + return actions +} + +describe('ShellCard', () => { + it('renders its one-liner alone in the summary view', () => { + renderCard({}, 'summary') + + expect(document.body.textContent).toBe(en.description) + expect(screen.queryByLabelText(en.timeoutMs)).toBeNull() + }) + + it('says the plugin is not loaded in place of its fields while its namespace is unavailable', () => { + renderCard({ available: false }) + + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + expect(screen.queryByLabelText(en.timeoutMs)).toBeNull() + }) + + it('shows its fields at once on its page, without a title of its own', () => { + renderCard() + + expect(screen.getByLabelText(en.timeoutMs)).toBeTruthy() + expect(screen.getByLabelText(en.maxOutputBytes)).toBeTruthy() + expect(screen.queryByText(en.title)).toBeNull() + }) + + it('stages an edit instead of writing it', () => { + const actions = renderCard() + + fireEvent.change(screen.getByLabelText(en.timeoutMs), { target: { value: '9000' } }) + + expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000') + expect(actions.save).not.toHaveBeenCalled() + }) + + it('offers the reset for an overridden field only, and addresses each field separately', () => { + const actions = renderCard({ maxOutputBytes: field('64000', { overridden: true }) }) + + // One badge and one reset: the timeout is still inherited. + expect(screen.getAllByText(en.overridden)).toHaveLength(1) + fireEvent.change(screen.getByLabelText(en.maxOutputBytes), { target: { value: '1024' } }) + fireEvent.click(screen.getByRole('button', { name: en.reset })) + + expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024') + expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes') + cleanup() + + const timeout = renderCard({ timeoutMs: field('9000', { overridden: true }) }) + fireEvent.click(screen.getByRole('button', { name: en.reset })) + expect(timeout.resetField).toHaveBeenCalledWith('timeoutMs') + }) + + it('keeps the save inert until something is staged, offers no discard, and writes the staged edits when saved', () => { + renderCard() + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + expect(screen.getAllByRole('button')).toHaveLength(1) + cleanup() + + const actions = renderCard({ dirty: true, timeoutMs: field('9000', { overridden: true }) }) + fireEvent.click(screen.getByRole('button', { name: en.save })) + + expect(actions.save).toHaveBeenCalledOnce() + expect(actions.discard).not.toHaveBeenCalled() + }) + + it('drops the staged edits when it leaves the page', () => { + const actions = renderCard({ dirty: true }) + expect(actions.discard).not.toHaveBeenCalled() + + cleanup() + + expect(actions.discard).toHaveBeenCalledOnce() + }) + + it('blocks the save while a draft is invalid and says why, reports a save in flight, and a save the deployment refused', () => { + renderCard({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) }) + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + expect(screen.getByText(en.invalidNumber)).toBeTruthy() + cleanup() + + renderCard({ dirty: true, saving: true }) + expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) + cleanup() + + renderCard({ dirty: true, failed: true }) + expect(screen.getByText(en.saveFailed)).toBeTruthy() + expect(screen.getByLabelText(en.timeoutMs)).toBeTruthy() + }) + + it('says the document is read-only and disables its controls', () => { + renderCard({ writable: false }) + + expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly) + expect(screen.getByLabelText(en.timeoutMs)).toHaveProperty('disabled', true) + }) +}) diff --git a/packages/client/ui-settings-shell/tests/controller.client.spec.ts b/packages/client/ui-settings-shell/tests/controller.client.spec.ts new file mode 100644 index 0000000000..d5958f79a5 --- /dev/null +++ b/packages/client/ui-settings-shell/tests/controller.client.spec.ts @@ -0,0 +1,91 @@ +/** The shell page's form over a scripted settings scope: what it projects and what a save writes. */ + +import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { ShellCardController, type ShellSettings } from '../src/client/shell-card-controller.ts' + +/** Make the stub behave like a Host that accepts every write. */ +function acceptWrites(host: StubSettingsScope): void { + const section = (): Record => ({ ...host.scope.getSnapshot().value as object }) + const layer = (): Record => ({ ...host.scope.getSnapshot().user as object }) + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) + }) + host.unset.mockImplementation((field: string) => { + const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) + const base = host.scope.getSnapshot().base as Record | undefined + host.publish({ value: { ...section(), [field]: base?.[field] } as T, user }) + }) +} + +describe('ShellCardController', () => { + it('projects both fields and saves them in one write pass', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const controller = new ShellCardController(host.scope) + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 5_000, maxOutputBytes: 64_000 }, + base: { timeoutMs: 60_000, maxOutputBytes: 64_000 }, + user: { timeoutMs: 5_000 }, + }) + const face = controller.inject() + + expect(face.hooks.shellCard.getSnapshot()).toMatchObject({ + available: true, + writable: true, + dirty: false, + timeoutMs: { text: '5000', overridden: true }, + maxOutputBytes: { text: '64000', overridden: false }, + }) + + face.edit('timeoutMs', '9000') + face.edit('maxOutputBytes', '1024') + expect(face.hooks.shellCard.getSnapshot().dirty).toBe(true) + + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) + + expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]]) + expect(face.hooks.shellCard.getSnapshot().dirty).toBe(false) + }) + + it('stages a reset and applies it on save', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const controller = new ShellCardController(host.scope) + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 5_000 }, + base: { timeoutMs: 60_000 }, + user: { timeoutMs: 5_000 }, + }) + const face = controller.inject() + + face.resetField('timeoutMs') + expect(face.hooks.shellCard.getSnapshot().timeoutMs.text).toBe('60000') + + face.save() + await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') }) + + expect(face.hooks.shellCard.getSnapshot()).toMatchObject({ + dirty: false, + timeoutMs: { text: '60000', overridden: false }, + }) + }) + + it('discards staged edits without writing', () => { + const host = stubSettingsScope() + const controller = new ShellCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} }) + const face = controller.inject() + + face.edit('timeoutMs', '9000') + face.discard() + + expect(face.hooks.shellCard.getSnapshot().timeoutMs.text).toBe('5000') + expect(host.set).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-settings-shell/tsconfig.json b/packages/client/ui-settings-shell/tsconfig.json new file mode 100644 index 0000000000..a43ca3698f --- /dev/null +++ b/packages/client/ui-settings-shell/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-plugin-manager" + }, + { + "path": "../ui-settings" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-settings-shell/tsdown.config.ts b/packages/client/ui-settings-shell/tsdown.config.ts new file mode 100644 index 0000000000..4e48f8dff3 --- /dev/null +++ b/packages/client/ui-settings-shell/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-shell', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-subagent/README.i18n.yaml b/packages/client/ui-settings-subagent/README.i18n.yaml new file mode 100644 index 0000000000..9d1c8c77fe --- /dev/null +++ b/packages/client/ui-settings-subagent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-settings-subagent/README.md +README.md: cb65a54c35e3d03b532a34682a1d9aa016721b59 +README.zh.md: 53e9918760bb3bde5b83820f31056f806f0720f5 diff --git a/packages/client/ui-settings-subagent/README.md b/packages/client/ui-settings-subagent/README.md new file mode 100644 index 0000000000..cb65a54c35 --- /dev/null +++ b/packages/client/ui-settings-subagent/README.md @@ -0,0 +1,82 @@ +--- +description: "The Subagent settings page on the dsh web client's Plugins page: delegation depth and capacity over the subagent namespace, and the models agents may choose over subagent-model-selection, on one page with one save." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-subagent + +English | [中文](README.zh.md) + +## Summary + +Open **Plugins** in the sidebar and select **Subagent** in the Official group to set how deep and how wide delegation may go, and which models agents may choose for their subagents. The page groups the two Host namespaces, `subagent` and `subagent-model-selection`, under one save; it exists while the Host serves either and shows the sections it serves. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +**Limits** holds **Maximum recursion depth** and **Subagent parallelism limit** side by side, stacking on narrow screens; the information button beside each label discloses its rule — a two-row depth example, and the count that capacity bounds — and a draft that is not a whole number in range blocks the save and says so under the field. Depth yields to a tool's own maximum; capacity counts every live subagent under one main agent across all levels, the main agent excluded. + +**Model selection** stages its permission switch and the exact adapter routes together. Enabling requires at least one selected route; disabling keeps the selected routes for later. Routes the Host stored but no adapter advertises stay listed under **Saved but currently unavailable** and remain removable; a provider that failed to load is reported without hiding the others, and a failed load offers **Retry**. + +One **Save** writes both sections through their own namespaces, each fenced by the revision its draft read. The writes are independent: a section the Host refuses keeps its draft with the failure reported while the other lands, and a model draft a newer Host revision superseded is reported as a conflict to discard rather than overwriting the newer routes. Leaving the page drops every draft. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds each namespace through `ctx.settingsScope`: `SubagentLimitsCardController` stages the limits over the shared `SettingsFormModel` of `ui-primitives`, with a field spec that admits safe whole numbers at or above each field's floor; `SubagentModelSelectionCardController` keeps its own draft because its two fields save as one revision-fenced `mutate`, joins the stored routes with `remote.session.modelCatalog()`, and re-reads the catalogue on `llm/adapters-updated` and `settings/document-updated` while a connection reset drops its draft. `subagentCardFace` composes the two into the one face `SubagentCard` renders inside the shared `SettingsForm`, whose save validates both and writes the dirty ones. The page registers into the Plugins page's `plugins.item` slot through `ctx.settingsScope.whileServed` over both namespaces. + +
+ +----- + + +## Further Exploration + +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. +- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. +- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the limits section renders. +- [tool-subagent](../../subagent/tool-subagent/README.md) — the delegation tools that register both namespaces. + +----- + + +## Model Experience + +None, as the package is a browser-side settings surface that registers no model surface. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **The catalogue re-reads on three signals only** — adapter changes, document commits, and reconnects; a provider that starts advertising without any of them shows up on the next of those or on **Retry**. +- **Runtime invariant:** No companion is published. The page holds no owned relationship of its own: what it shows derives from the settings mirror and the model catalogue, and what it writes the Host validates. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-settings-subagent/README.zh.md b/packages/client/ui-settings-subagent/README.zh.md new file mode 100644 index 0000000000..53e9918760 --- /dev/null +++ b/packages/client/ui-settings-subagent/README.zh.md @@ -0,0 +1,82 @@ +--- +description: "dsh Web 客户端插件页上的 Subagent 设置页:subagent 命名空间的委派深度与并行容量,以及 subagent-model-selection 里 Agent 可选的模型,同一页一次保存。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-subagent + +[English](README.md) | 中文 + +## 概述 + +在侧栏打开**插件**,在官方分组里选择 **Subagent**,即可设置委派可以多深、多宽,以及 Agent 可以为 Subagent 选择哪些模型。页面把 Host 的两个命名空间 `subagent` 与 `subagent-model-selection` 收在一次保存之下;Host 服务其中任一命名空间时页面就存在,并只显示被服务的部分。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +**运行限制**把**最大递归深度**和 **Subagent 并行数量上限**并排放置,窄屏时上下堆叠;每个标签旁的信息按钮展开它的规则——深度是一张两行示例表,容量是计数口径——填了不在范围内的整数会阻止保存并在字段下说明。深度让位于工具自己的上限;容量统计同一主 Agent 下所有层级存活的 Subagent,主 Agent 不计入。 + +**模型选择**把权限开关和精确的适配器路由一起暂存。开启时至少要选一条路由;关闭会保留已选路由以备后用。Host 已存储但没有适配器公布的路由留在**已保存但当前不可用**分组里并且仍可移除;加载失败的提供方会被报告而不隐藏其他提供方,加载失败时提供**重试**。 + +一次**保存**通过各自的命名空间写入两个部分,每次写入都以其草稿读取时的修订号作栅栏。两次写入相互独立:Host 拒绝的部分保留草稿并报告失败,另一部分照常落地;被更新的 Host 修订号超越的模型草稿会以冲突形式报告并要求放弃,而不是覆盖较新的路由。离开页面即丢弃所有草稿。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.settingsScope` 分别绑定两个命名空间:`SubagentLimitsCardController` 用 `ui-primitives` 的共享 `SettingsFormModel` 暂存运行限制,字段规格只接受不低于各自下限的安全整数;`SubagentModelSelectionCardController` 自己维护草稿,因为它的两个字段要作为一次带修订栅栏的 `mutate` 保存,它把已存储的路由与 `remote.session.modelCatalog()` 合并,在 `llm/adapters-updated` 与 `settings/document-updated` 时重读目录,连接重置时丢弃草稿。`subagentCardFace` 把两者合成 `SubagentCard` 在共享 `SettingsForm` 里渲染的一个 face,保存时校验两部分并写入有改动的那些。页面通过 `ctx.settingsScope.whileServed` 监视两个命名空间,注册进插件页的 `plugins.item` slot。 + +
+ +----- + + +## 进一步探索 + +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 +- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 +- [ui-primitives](../ui-primitives/README.zh.md)——运行限制部分渲染的设置表单模型与字段。 +- [tool-subagent](../../subagent/tool-subagent/README.zh.md)——注册这两个命名空间的委派工具。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的设置界面,不注册任何模型面。 + +#### KV 缓存影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **目录只在三种信号下重读**——适配器变化、文档提交和重连;在这三者之外开始公布模型的提供方,要等下一次信号或点击**重试**才会出现。 +- **运行时不变量:**不发布伴生。本页没有自己拥有的关系:它显示的内容派生自设置镜像与模型目录,它写入的内容由 Host 校验。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-settings-subagent/package.json b/packages/client/ui-settings-subagent/package.json new file mode 100644 index 0000000000..49ccf5295f --- /dev/null +++ b/packages/client/ui-settings-subagent/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-subagent", + "description": "Settings page of Subagent delegation on the dsh web client's Plugins page: recursion depth, parallel capacity, and the models agents may choose for subagents", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-subagent" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentCard.module.css b/packages/client/ui-settings-subagent/src/client/SubagentCard.module.css similarity index 100% rename from packages/client/ui-settings-plugins/src/client/SubagentCard.module.css rename to packages/client/ui-settings-subagent/src/client/SubagentCard.module.css diff --git a/packages/client/ui-settings-plugins/src/client/SubagentCard.tsx b/packages/client/ui-settings-subagent/src/client/SubagentCard.tsx similarity index 90% rename from packages/client/ui-settings-plugins/src/client/SubagentCard.tsx rename to packages/client/ui-settings-subagent/src/client/SubagentCard.tsx index 685fa89285..42d2991c16 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentCard.tsx +++ b/packages/client/ui-settings-subagent/src/client/SubagentCard.tsx @@ -3,7 +3,8 @@ import { useId } from 'react' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' -import { PluginConfigForm } from './PluginConfigForm.tsx' +import { SettingsForm } from '@deepseek-ai/dsh-client-ui-primitives' +import { formLabels } from './locales.ts' import { SubagentLimitsFields } from './SubagentLimitsFields.tsx' import { SubagentModelSelectionFields } from './SubagentModelSelectionFields.tsx' import { subagentCardShell, type SubagentCardFace } from './subagent-card-controller.ts' @@ -11,7 +12,7 @@ import css from './SubagentCard.module.css' /** Framework-derived props for the shared Subagent settings card. */ export type SubagentCardProps = PropsRuntime<'plugins.item'> - & PropsLocale<'settings.plugins'> & InjectFace + & PropsLocale<'settings.subagent'> & InjectFace /** * Render the available Subagent settings with one configuration page and save footer. @@ -26,7 +27,7 @@ export function SubagentCard(props: SubagentCardProps) { if (props.view === 'summary') return t('subagentDescription') const state = subagentCardShell(limits, models) return ( - {limits.available ? ( @@ -46,6 +47,6 @@ export function SubagentCard(props: SubagentCardProps) {
) : null} - + ) } diff --git a/packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.module.css b/packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.module.css similarity index 100% rename from packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.module.css rename to packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.module.css diff --git a/packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx b/packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.tsx similarity index 87% rename from packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx rename to packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.tsx index e1b732e1fc..2560850325 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx +++ b/packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.tsx @@ -1,12 +1,12 @@ /** Delegation-limit fields inside the shared Subagent settings card. */ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import { ValueField } from './fields.tsx' +import { SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives' import type { SubagentLimitsCardFace, SubagentLimitsCardState } from './subagent-limits-card-controller.ts' import css from './SubagentLimitsFields.module.css' /** Plain state and edit callbacks supplied by the owning card. */ -export type SubagentLimitsFieldsProps = PropsLocale<'settings.plugins'> +export type SubagentLimitsFieldsProps = PropsLocale<'settings.subagent'> & Pick & { state: SubagentLimitsCardState } @@ -21,7 +21,7 @@ export function SubagentLimitsFields(props: SubagentLimitsFieldsProps) { <>
-

{t('subagentDepthHelp')}

@@ -45,7 +45,7 @@ export function SubagentLimitsFields(props: SubagentLimitsFieldsProps) { onEdit={(text) => { props.edit('maxDepth', text) }} onReset={() => { props.resetField('maxDepth') }} />
- {t('subagentCapacityHelp')}

}} overriddenLabel={t('overridden')} resetLabel={t('reset')} invalidLabel={t('subagentCapacityInvalid')} numeric disabled={!state.writable || state.saving} {...state.maxActiveSubagents} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.module.css b/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.module.css similarity index 100% rename from packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.module.css rename to packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.module.css diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx b/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.tsx similarity index 99% rename from packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx rename to packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.tsx index 6aa6fe2dd4..39d733f688 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx +++ b/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.tsx @@ -10,7 +10,7 @@ import type { import css from './SubagentModelSelectionFields.module.css' /** Plain model state and callbacks supplied by the owning Subagent card. */ -export type SubagentModelSelectionFieldsProps = PropsLocale<'settings.plugins'> +export type SubagentModelSelectionFieldsProps = PropsLocale<'settings.subagent'> & Pick & { state: SubagentModelSelectionCardState } diff --git a/packages/client/ui-settings-subagent/src/client/index.ts b/packages/client/ui-settings-subagent/src/client/index.ts new file mode 100644 index 0000000000..cdaab36259 --- /dev/null +++ b/packages/client/ui-settings-subagent/src/client/index.ts @@ -0,0 +1,92 @@ +/** + * The Subagent settings page, browser half: the delegation limits over the + * `subagent` namespace and the models agents may choose over the + * `subagent-model-selection` namespace, on one page with one save. The page + * registers into the Plugins page's `plugins.item` slot while the Host serves + * either namespace and shows the sections it serves. + */ + +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remote Context merge and the forwarded-event key face. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { SubagentCard } from './SubagentCard.tsx' +import { subagentCardFace } from './subagent-card-controller.ts' +import { SubagentLimitsCardController } from './subagent-limits-card-controller.ts' +import { + SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, +} from './subagent-model-selection-card-controller.ts' +import { en, zh, type SubagentSettingsLocaleKey } from './locales.ts' + +export type { SubagentCardProps } from './SubagentCard.tsx' +export type { SubagentCardFace } from './subagent-card-controller.ts' +export type { SubagentLimitsCardFace, SubagentLimitsCardState, SubagentLimitsSettings } from './subagent-limits-card-controller.ts' +export type { + SubagentModelSelectionCardFace, SubagentModelSelectionCardState, SubagentModelSelectionSettings, +} from './subagent-model-selection-card-controller.ts' +export type { SubagentSettingsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Subagent settings page copy. */ + 'settings.subagent': SubagentSettingsLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.subagent' + +/** + * Namespace of the delegation limits. Spelled here rather than imported: a + * client package must not depend on a Host package. + */ +export const SUBAGENT_NS = 'subagent' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'remote', 'remote.session', 'settingsScope'] + +/** + * Mount the Subagent settings page while the Host serves either of its namespaces. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-subagent: dictionaries') + const limits = new SubagentLimitsCardController(ctx.settingsScope.bind({ namespace: SUBAGENT_NS })) + const models = new SubagentModelSelectionCardController( + ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), + ctx, + ) + const limitsFace = limits.inject() + const modelsFace = models.inject() + // The model catalogue is not part of any settings section: adapters come and + // go, and a document commit elsewhere can change which routes are stored. + ctx.effect( + () => ctx.remote.$on('llm/adapters-updated', () => { models.refreshCatalog() }), + 'ui-settings-subagent: adapter invalidations', + ) + ctx.effect( + () => ctx.remote.$on('settings/document-updated', () => { models.refreshCatalog() }), + 'ui-settings-subagent: settings invalidations', + ) + ctx.effect( + () => ctx.on('connection/reset', () => { models.resetConnection() }), + 'ui-settings-subagent: connection generation', + ) + ctx.effect(() => () => { models.dispose() }, 'ui-settings-subagent: model preference') + ctx.effect(() => ctx.settingsScope.whileServed([SUBAGENT_NS, SUBAGENT_MODEL_SELECTION_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', + id: 'subagent', + order: 30, + label: () => t('subagentTitle'), + locale: NS, + inject: () => subagentCardFace(limitsFace, modelsFace), + }, SubagentCard))), 'ui-settings-subagent: page') +} diff --git a/packages/client/ui-settings-subagent/src/client/locales.ts b/packages/client/ui-settings-subagent/src/client/locales.ts new file mode 100644 index 0000000000..5b05cd74f1 --- /dev/null +++ b/packages/client/ui-settings-subagent/src/client/locales.ts @@ -0,0 +1,109 @@ +/** Locale bundles for the Subagent settings page. */ + +import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Locale keys the page renders. */ +export type SubagentSettingsLocaleKey = + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' + | 'subagentTitle' | 'subagentDescription' | 'subagentLimitsTitle' + | 'subagentMaxDepth' + | 'subagentDepthHelpLabel' | 'subagentDepthHelp' + | 'subagentDepthZero' | 'subagentDepthOne' | 'subagentDepthOverride' + | 'subagentMaxActive' + | 'subagentCapacityHelpLabel' | 'subagentCapacityHelp' + | 'subagentDepthInvalid' + | 'subagentCapacityInvalid' + | 'subagentModelSelectionTitle' + | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' + | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' + | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' + | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff' + +/** English copy. */ +export const en: Record = { + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + save: 'Save', + saving: 'Saving…', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + subagentTitle: 'Subagent', + subagentDescription: 'Set Subagent recursion depth, count, and models.', + subagentLimitsTitle: 'Limits', + subagentMaxDepth: 'Maximum recursion depth', + subagentDepthHelpLabel: 'About maximum recursion depth', + subagentDepthHelp: 'Limits how many levels of Subagents an Agent can create.', + subagentDepthZero: 'Disable Subagents', + subagentDepthOne: 'Only the main Agent can create Subagents', + subagentDepthOverride: 'If a tool defines its own maximum recursion depth, that setting takes precedence.', + subagentMaxActive: 'Subagent parallelism limit', + subagentCapacityHelpLabel: 'About the Subagent parallelism limit', + subagentCapacityHelp: 'Total live Subagents under the same main Agent, across all recursion levels. The main Agent is excluded. New start requests are rejected when the limit is reached.', + subagentDepthInvalid: 'Enter a whole number of 0 or more.', + subagentCapacityInvalid: 'Enter a whole number of 1 or more.', + subagentModelSelectionTitle: 'Model selection', + subagentModelSelectionToggle: 'Allow agents to choose models for Subagents', + subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each Subagent from the authorized models below. Applies only to new sessions.', + subagentModelSelectionAllowed: 'Models agents may choose', + subagentModelSelectionLoading: 'Loading models…', + subagentModelSelectionLoadFailed: 'Models could not be loaded.', + subagentModelSelectionRetry: 'Retry', + subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.', + subagentModelSelectionUnavailable: 'Currently unavailable', + subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', + subagentModelSelectionEmpty: 'No model provider currently advertises a model.', + subagentModelSelectionRequired: 'Select at least one model before saving.', + subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.', + subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + unavailable: '该插件当前未加载,暂时无法配置。', + save: '保存', + saving: '保存中…', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + subagentTitle: 'Subagent', + subagentDescription: '设置 Subagent 的递归层级、数量和模型。', + subagentLimitsTitle: '运行限制', + subagentMaxDepth: '最大递归深度', + subagentDepthHelpLabel: '最大递归深度说明', + subagentDepthHelp: '限制 Agent 创建 Subagent 的递归层级。', + subagentDepthZero: '禁用 Subagent', + subagentDepthOne: '仅允许主 Agent 创建 Subagent', + subagentDepthOverride: '如果某个工具单独设置了最大递归深度,以该工具的设置为准。', + subagentMaxActive: 'Subagent 并行数量上限', + subagentCapacityHelpLabel: 'Subagent 并行数量上限说明', + subagentCapacityHelp: '同一主 Agent 下,所有递归层级同时存活的 Subagent 总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', + subagentDepthInvalid: '请输入不小于 0 的整数。', + subagentCapacityInvalid: '请输入不小于 1 的整数。', + subagentModelSelectionTitle: '模型选择', + subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型', + subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。', + subagentModelSelectionAllowed: 'Agent 可选择的模型', + subagentModelSelectionLoading: '正在加载模型…', + subagentModelSelectionLoadFailed: '无法加载模型。', + subagentModelSelectionRetry: '重试', + subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。', + subagentModelSelectionUnavailable: '当前不可用', + subagentModelSelectionUnavailableGroup: '已保存但当前不可用', + subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', + subagentModelSelectionRequired: '保存前请至少选择一个模型。', + subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。', + subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', +} + +/** + * The form frame's copy, read from this page's dictionary. + * @param t - the page's locale reader. + * @returns the labels the shared settings form renders. + */ +export function formLabels(t: (key: SubagentSettingsLocaleKey) => string): SettingsFormLabels { + return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') } +} diff --git a/packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts b/packages/client/ui-settings-subagent/src/client/subagent-card-controller.ts similarity index 96% rename from packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts rename to packages/client/ui-settings-subagent/src/client/subagent-card-controller.ts index ad217e1225..5e46ab2e87 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts +++ b/packages/client/ui-settings-subagent/src/client/subagent-card-controller.ts @@ -1,6 +1,6 @@ /** Shared presentation and actions for the two Host-owned Subagent settings sections. */ -import type { CardShell } from './card-form.ts' +import type { SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' import type { SubagentLimitsCardFace, SubagentLimitsCardState } from './subagent-limits-card-controller.ts' import type { SubagentModelSelectionCardFace, SubagentModelSelectionCardState, @@ -29,7 +29,7 @@ export interface SubagentCardFace { export function subagentCardShell( limits: SubagentLimitsCardState, models: SubagentModelSelectionCardState, -): CardShell { +): SettingsFormShell { const sections = [limits, models].filter(section => section.available) return { available: sections.length > 0, diff --git a/packages/client/ui-settings-plugins/src/client/subagent-limits-card-controller.ts b/packages/client/ui-settings-subagent/src/client/subagent-limits-card-controller.ts similarity index 67% rename from packages/client/ui-settings-plugins/src/client/subagent-limits-card-controller.ts rename to packages/client/ui-settings-subagent/src/client/subagent-limits-card-controller.ts index cfb566961f..15b5292b86 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-limits-card-controller.ts +++ b/packages/client/ui-settings-subagent/src/client/subagent-limits-card-controller.ts @@ -1,8 +1,10 @@ /** Staged delegation limits backed by the Host's subagent settings section. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import { CardForm, numberField, type CardActions, type CardFieldSpec, type CardFieldState, type CardShell } from './card-form.ts' +import { + SettingsFormModel, settingsNumberField, + type SettingsFieldSpec, type SettingsFieldState, type SettingsFormActions, type SettingsFormScope, type SettingsFormShell, +} from '@deepseek-ai/dsh-client-ui-primitives' /** Host-owned delegation defaults and live capacity. */ export interface SubagentLimitsSettings { @@ -11,20 +13,20 @@ export interface SubagentLimitsSettings { } /** Effective values and drafts presented by the limits card. */ -export interface SubagentLimitsCardState extends CardShell { - maxDepth: CardFieldState - maxActiveSubagents: CardFieldState +export interface SubagentLimitsCardState extends SettingsFormShell { + maxDepth: SettingsFieldState + maxActiveSubagents: SettingsFieldState } /** Actions and observable state bound by the slot renderer. */ -export interface SubagentLimitsCardFace extends CardActions { +export interface SubagentLimitsCardFace extends SettingsFormActions { hooks: { subagentLimitsCard: SnapshotStore } } -function limitField(field: keyof SubagentLimitsSettings, minimum: number): CardFieldSpec { - const numeric = numberField(field) +function limitField(field: keyof SubagentLimitsSettings, minimum: number): SettingsFieldSpec { + const numeric = settingsNumberField(field) return { ...numeric, parse: (text) => { @@ -38,12 +40,12 @@ function limitField(field: keyof SubagentLimitsSettings, minimum: number): CardF /** Bind two independently resettable limits to one staged settings form. */ export class SubagentLimitsCardController { - private readonly form: CardForm + private readonly form: SettingsFormModel private readonly store: SnapshotStore /** @param scope - The Host's `subagent` settings section. */ - constructor(scope: SettingsScope) { - this.form = new CardForm(scope, [limitField('maxDepth', 0), limitField('maxActiveSubagents', 1)]) + constructor(scope: SettingsFormScope) { + this.form = new SettingsFormModel(scope, [limitField('maxDepth', 0), limitField('maxActiveSubagents', 1)]) this.store = this.form.bind(() => ({ ...this.form.shell(), maxDepth: this.form.field('maxDepth'), diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-subagent/src/client/subagent-model-selection-card-controller.ts similarity index 98% rename from packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts rename to packages/client/ui-settings-subagent/src/client/subagent-model-selection-card-controller.ts index 96e5b8c494..75c5d86299 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-subagent/src/client/subagent-model-selection-card-controller.ts @@ -4,7 +4,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { CardShell } from './card-form.ts' +import type { SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' /** Namespace of the Host-owned subagent model-selection preference. */ export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection' @@ -38,7 +38,7 @@ export interface SubagentModelCandidate extends AllowedSubagentModel { } /** State rendered by the staged allowlist card. */ -export interface SubagentModelSelectionCardState extends CardShell { +export interface SubagentModelSelectionCardState extends SettingsFormShell { /** Whether the draft enables model-facing child route selection. */ enabled: boolean /** Live catalog joined with stored routes. */ diff --git a/packages/client/ui-settings-subagent/src/css-modules.d.ts b/packages/client/ui-settings-subagent/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-settings-subagent/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-settings-subagent/src/index.ts b/packages/client/ui-settings-subagent/src/index.ts new file mode 100644 index 0000000000..4360878c3d --- /dev/null +++ b/packages/client/ui-settings-subagent/src/index.ts @@ -0,0 +1,11 @@ +/** + * Subagent settings page, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half owns the page + * through exports["./client"], discovered from the package.json dsh.client + * declaration. The `subagent` and `subagent-model-selection` namespaces the + * page edits are registered by the delegation plugins, so this package + * registers no namespace of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-subagent/tests/apply.client.spec.ts b/packages/client/ui-settings-subagent/tests/apply.client.spec.ts new file mode 100644 index 0000000000..edfe36b27e --- /dev/null +++ b/packages/client/ui-settings-subagent/tests/apply.client.spec.ts @@ -0,0 +1,117 @@ +/** What the browser half registers, when, what refreshes the model catalogue, and that it all leaves with the fiber. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject, NS } from '../src/client/index.ts' +import type { SubagentCardFace } from '../src/client/index.ts' +import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' +import { apply as hostApply } from '../src/index.ts' + +/** One Host view of a served namespace. */ +function view(ns: string, revision = 0) { + return { ns, schema: {}, value: {}, applies: 'live', secrets: [], revision } +} + +/** @param served - namespaces the Host describes; omitted answers a failed read. */ +async function bench(served?: string[]) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + locale.setLocale('zh') + ctx.provide('locale', locale) + const models = vi.fn(() => Promise.resolve({ ok: true as const, value: { groups: [], failures: [] } })) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } + : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const remote = new TestRemote(ctx, { + session: { modelCatalog: models }, + settings: { describe: describeSettings }, + }) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeSettings, models, remote } +} + +/** The Plugins page's item slot, as its owner declares it. */ +function declareRoot(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'plugins.item': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-subagent apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.session', 'settingsScope']) + }) + + it('registers one page for both namespaces while the Host serves either, titled in the active locale', async () => { + const { ctx, slots } = await bench(['subagent', 'subagent-model-selection']) + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + const entry = slots.entries('plugins.item')[0]! + expect(entry.options).toMatchObject({ id: 'subagent', order: 30 }) + expect(resolveSlotLabel(entry.options.label)).toBe('Subagent') + expect(entry.locale).toBe(NS) + const face = (entry.inject as unknown as () => SubagentCardFace)() + expect(Object.keys(face.hooks).sort()).toEqual(['subagentLimitsCard', 'subagentModelSelectionCard']) + }) + + it('registers the page when only the model namespace is served, and nothing when neither is', async () => { + const { ctx, slots, describeSettings } = await bench(['subagent-model-selection']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + await ctx.plugin({ inject: [...inject], apply }).dispose() + expect(slots.entries('plugins.item')).toHaveLength(1) + + const other = await bench(['shell']) + declareRoot(other.slots) + await other.ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(other.describeSettings).toHaveBeenCalled() }) + expect(other.slots.entries('plugins.item')).toHaveLength(0) + expect(describeSettings).toHaveBeenCalled() + }) + + it('refreshes the model catalogue after model inputs change or the connection resets', async () => { + const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') + const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection') + const { ctx, slots, remote } = await bench(['subagent-model-selection']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + refresh.mockClear() + reset.mockClear() + + remote.emit('llm/adapters-updated', []) + expect(refresh).toHaveBeenCalledTimes(1) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(refresh).toHaveBeenCalledTimes(2) + ctx.emit('connection/reset') + expect(reset).toHaveBeenCalledTimes(1) + refresh.mockRestore() + reset.mockRestore() + }) + + it('collapses the page on teardown', async () => { + const { ctx, slots } = await bench(['subagent']) + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + await fiber.dispose() + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-settings-subagent/tests/card.client.spec.tsx b/packages/client/ui-settings-subagent/tests/card.client.spec.tsx new file mode 100644 index 0000000000..d0efaf23f8 --- /dev/null +++ b/packages/client/ui-settings-subagent/tests/card.client.spec.tsx @@ -0,0 +1,292 @@ +// @vitest-environment jsdom +/** The Subagent page as the Plugins page renders it: both sections on one page with one save. */ + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { SubagentCard, type SubagentCardProps } from '../src/client/SubagentCard.tsx' +import type { SubagentLimitsCardState } from '../src/client/subagent-limits-card-controller.ts' +import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +const settled: SettingsFormShell = { + available: true, + writable: true, + dirty: false, + invalid: false, + saving: false, + failed: false, +} + +function field(text: string, rest: Partial = {}): SettingsFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +/** The Plugins page asks a configuration entry for its one-liner or its form; every page renders as `page` unless a test says otherwise. */ +type ConfigView = 'summary' | 'page' + +function renderSubagent( + limitState: Partial = {}, + modelState: Partial = {}, + view: ConfigView = 'page', +) { + const limits = createSnapshotStore({ + ...settled, + maxDepth: field('3'), + maxActiveSubagents: field('8'), + ...limitState, + }) + const models = createSnapshotStore({ + ...settled, + enabled: false, + candidates: [], + catalogStatus: 'idle', + catalogPartial: false, + conflicted: false, + ...modelState, + }) + const actions = { + editLimit: vi.fn(), + resetLimit: vi.fn(), + toggleEnabled: vi.fn(), + toggleModel: vi.fn(), + retryCatalog: vi.fn(), + save: vi.fn(), + discard: vi.fn(), + } + const props = { + ...actions, + view, + t, + useSubagentLimitsCard: bindSnapshotSelector(limits), + useSubagentModelSelectionCard: bindSnapshotSelector(models), + } as unknown as SubagentCardProps + render() + return { actions, limits, models } +} + +function renderSubagentModelSelection(state: Partial = {}, view: ConfigView = 'page') { + return renderSubagent({ available: false }, state, view).actions +} + +describe('Subagent model selection fields', () => { + it('renders the default-off preference in its staged plugin card', () => { + const actions = renderSubagentModelSelection() + + const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(control.getAttribute('aria-checked')).toBe('false') + fireEvent.click(control) + + expect(actions.toggleEnabled).toHaveBeenCalledOnce() + }) + + it('groups available adapter candidates by provider', () => { + const actions = renderSubagentModelSelection({ + enabled: true, + candidates: [ + { + key: 'alpha\0fast', + provider: 'alpha', + model: 'fast', + providerName: 'Alpha API', + modelName: 'Fast', + available: true, + selected: true, + }, + { + key: 'alpha\0deep', + provider: 'alpha', + model: 'deep', + providerName: 'Alpha API', + modelName: 'Deep', + available: true, + selected: false, + }, + ], + catalogStatus: 'ready', + }) + + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') + expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() + fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) + fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ })) + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep') + }) + + it('renders directory progress, failures, unavailable routes, and validation', () => { + renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) + expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() + + cleanup() + const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) + fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) + expect(errorActions.retryCatalog).toHaveBeenCalledOnce() + + cleanup() + renderSubagentModelSelection({ + enabled: true, + catalogStatus: 'ready', + catalogPartial: true, + candidates: [{ + key: 'legacy\0old', + provider: 'legacy', + model: 'old', + providerName: 'legacy', + modelName: 'old', + available: false, + selected: true, + }], + }) + expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() + + cleanup() + renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) + expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() + }) + + it('distinguishes a stale draft from a rejected save', () => { + renderSubagentModelSelection({ dirty: true, conflicted: true }) + + expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() + expect(screen.queryByText(en.saveFailed)).toBeNull() + }) + + it('renders its one-liner in the summary view, says so when unavailable, and disables writes when read-only', () => { + renderSubagentModelSelection({}, 'summary') + expect(document.body.textContent).toBe(en.subagentDescription) + + cleanup() + renderSubagentModelSelection({ available: false }) + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + + cleanup() + const actions = renderSubagentModelSelection({ writable: false }) + const control = screen.getByRole('switch') as HTMLButtonElement + expect(control.disabled).toBe(true) + fireEvent.click(control) + expect(actions.toggleEnabled).not.toHaveBeenCalled() + }) +}) + +describe('SubagentCard', () => { + it('discards both drafts when leaving the page', () => { + const { actions } = renderSubagent({ dirty: true }, { dirty: true }) + cleanup() + expect(actions.discard).toHaveBeenCalledOnce() + }) + + it('renders limits without model selection when only limits are served', () => { + renderSubagent({}, { available: false }) + expect(screen.getByLabelText(en.subagentMaxDepth)).toBeTruthy() + expect(screen.queryByRole('switch')).toBeNull() + }) + + it('shows both sections on one page with one save footer', () => { + renderSubagent({ dirty: true }) + + expect(screen.getByRole('heading', { name: en.subagentLimitsTitle })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.subagentModelSelectionTitle })).toBeTruthy() + expect(screen.getAllByRole('button', { name: en.save })).toHaveLength(1) + }) + + it('reveals field rules on demand without changing staged values', () => { + renderSubagent({ dirty: true, maxDepth: field('2') }) + const depthHelp = screen.getByRole('button', { name: en.subagentDepthHelpLabel }) + const capacityHelp = screen.getByRole('button', { name: en.subagentCapacityHelpLabel }) + expect(depthHelp.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText(en.subagentDepthHelp)).toBeNull() + expect(screen.queryByText(en.subagentCapacityHelp)).toBeNull() + + fireEvent.click(depthHelp) + expect(depthHelp.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('region', { name: en.subagentDepthHelpLabel })).toBeTruthy() + expect(screen.getByText(en.subagentDepthHelp)).toBeTruthy() + expect(screen.getByRole('table', { name: en.subagentDepthHelpLabel })).toBeTruthy() + expect(screen.getByRole('row', { name: `0 ${en.subagentDepthZero}` })).toBeTruthy() + expect(screen.getByRole('row', { name: `1 ${en.subagentDepthOne}` })).toBeTruthy() + expect(screen.getByText(en.subagentDepthOverride)).toBeTruthy() + fireEvent.click(capacityHelp) + expect(screen.getByText(en.subagentCapacityHelp)).toBeTruthy() + fireEvent.click(depthHelp) + expect(screen.queryByText(en.subagentDepthHelp)).toBeNull() + expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('value', '2') + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false) + }) + + it('keeps validation visible when the rules are collapsed and links it to the input', () => { + renderSubagent({ dirty: true, invalid: true, maxDepth: field('1.5', { invalid: true }) }) + const depth = screen.getByLabelText(en.subagentMaxDepth) + const messageId = depth.getAttribute('aria-describedby')! + expect(document.getElementById(messageId)?.textContent).toBe(en.subagentDepthInvalid) + expect(screen.queryByRole('region', { name: en.subagentDepthHelpLabel })).toBeNull() + }) + + it('edits and resets limits through the shared card', () => { + const { actions, limits } = renderSubagent({ + dirty: true, + maxDepth: field('3', { overridden: true }), + maxActiveSubagents: field('8', { overridden: true }), + }) + fireEvent.change(screen.getByLabelText(en.subagentMaxDepth), { target: { value: '2' } }) + fireEvent.change(screen.getByLabelText(en.subagentMaxActive), { target: { value: '12' } }) + expect(actions.editLimit.mock.calls).toEqual([['maxDepth', '2'], ['maxActiveSubagents', '12']]) + for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button) + expect(actions.resetLimit.mock.calls).toEqual([['maxDepth'], ['maxActiveSubagents']]) + act(() => { limits.set({ ...limits.getSnapshot(), writable: false }) }) + expect(screen.getByLabelText(en.subagentMaxActive)).toHaveProperty('disabled', true) + }) + + it('blocks saving both sections when a model selection is invalid or conflicted', () => { + const { models } = renderSubagent({ dirty: true }, { dirty: true, invalid: true }) + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + act(() => { models.set({ ...models.getSnapshot(), invalid: false, conflicted: true }) }) + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + }) + + it('locks both sections while either is saving and stays open after both settle', () => { + const { limits, models } = renderSubagent({ dirty: true }, { dirty: true }) + act(() => { + limits.set({ ...limits.getSnapshot(), saving: true }) + models.set({ ...models.getSnapshot(), saving: true }) + }) + expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('disabled', true) + expect(screen.getByRole('switch')).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) + act(() => { limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) }) + expect(screen.getByRole('switch')).toHaveProperty('disabled', true) + act(() => { models.set({ ...models.getSnapshot(), saving: false, dirty: false }) }) + expect(screen.getByRole('switch')).toHaveProperty('disabled', false) + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + }) + + it('keeps a rejected section open after the other section saves', () => { + const { limits, models } = renderSubagent({ dirty: true }, { dirty: true }) + act(() => { + limits.set({ ...limits.getSnapshot(), saving: true }) + models.set({ ...models.getSnapshot(), saving: true }) + }) + act(() => { + limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) + models.set({ ...models.getSnapshot(), saving: false, failed: true }) + }) + expect(screen.getByRole('switch')).toBeTruthy() + expect(screen.getByText(en.saveFailed)).toBeTruthy() + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false) + }) + + it('renders model-only deployments without limit controls', () => { + renderSubagent({ available: false }) + expect(screen.getByRole('switch')).toBeTruthy() + expect(screen.queryByLabelText(en.subagentMaxDepth)).toBeNull() + }) +}) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-subagent/tests/controllers.client.spec.ts similarity index 57% rename from packages/client/ui-settings-plugins/tests/stores.client.spec.ts rename to packages/client/ui-settings-subagent/tests/controllers.client.spec.ts index 80da2dbb56..5607ccfb2e 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-subagent/tests/controllers.client.spec.ts @@ -6,17 +6,13 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { SubagentLimitsCardController, type SubagentLimitsSettings } from '../src/client/subagent-limits-card-controller.ts' import { subagentCardFace, subagentCardShell } from '../src/client/subagent-card-controller.ts' -import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' -import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts' import { SubagentModelSelectionCardController, subagentModelCandidates, type SubagentModelSelectionSettings, } from '../src/client/subagent-model-selection-card-controller.ts' -import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ function acceptWrites(host: StubSettingsScope): void { @@ -49,15 +45,6 @@ function ctxWith(namespaces: object) { return { remote: namespaces } as never } -function credentialsApi(configured: boolean) { - const describe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { DEEPSEEK_API_KEY: { configured, writable: true } }, - })) - const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) - return { ctx: ctxWith({ credentials: { describe, set } }), describe, set } -} - function modelsApi(options: { groups?: readonly { id: string @@ -85,352 +72,6 @@ function deferred() { return { promise, resolve, reject } } -describe('CardForm', () => { - function form() { - const host = stubSettingsScope>() - const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')]) - host.publish({ - status: 'ready', - writable: true, - value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, - base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, - user: {}, - }) - return { host, subject } - } - - it('shows the effective value and stays clean until something is staged', () => { - const { subject } = form() - - expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) - expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false }) - }) - - it('marks a field the user layer carries as overridden', () => { - const { host, subject } = form() - - host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } }) - - // An override equal to the composition default is still an override. - expect(subject.field('timeoutMs').overridden).toBe(true) - }) - - it('writes nothing until the form is saved', async () => { - const { host, subject } = form() - acceptWrites(host) - - subject.actions().edit('timeoutMs', '9000') - - expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false }) - expect(subject.shell().dirty).toBe(true) - expect(host.set).not.toHaveBeenCalled() - - await subject.save() - - expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]]) - expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false }) - }) - - it('drops a draft that settles back on the value already shown', async () => { - const { host, subject } = form() - - subject.actions().edit('timeoutMs', '9000') - subject.actions().edit('timeoutMs', '60000') - - expect(subject.shell().dirty).toBe(false) - await subject.save() - - expect(host.set).not.toHaveBeenCalled() - }) - - it('refuses to save while a draft is not a value the field accepts', async () => { - const { host, subject } = form() - - subject.actions().edit('timeoutMs', 'soon') - - expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true }) - expect(subject.shell()).toMatchObject({ dirty: true, invalid: true }) - - await subject.save() - - expect(host.set).not.toHaveBeenCalled() - expect(subject.field('timeoutMs').text).toBe('soon') - }) - - it('stages a reset that clears the field only once saved', async () => { - const { host, subject } = form() - acceptWrites(host) - host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } }) - - subject.actions().resetField('timeoutMs') - - // The badge previews the save: the field will no longer be overridden. - expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) - expect(host.unset).not.toHaveBeenCalled() - - await subject.save() - - expect(host.unset.mock.calls).toEqual([['timeoutMs']]) - expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) - }) - - it('treats resetting an inherited field as no change at all', async () => { - const { host, subject } = form() - - subject.actions().resetField('timeoutMs') - - expect(subject.shell().dirty).toBe(false) - await subject.save() - - expect(host.unset).not.toHaveBeenCalled() - }) - - it('clears a number field by emptying it', async () => { - const { host, subject } = form() - acceptWrites(host) - host.publish({ user: { timeoutMs: 9_000 } }) - - subject.actions().edit('timeoutMs', '') - - expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false }) - await subject.save() - - expect(host.unset.mock.calls).toEqual([['timeoutMs']]) - }) - - it('clears a text field by emptying it', async () => { - const { host, subject } = form() - acceptWrites(host) - host.publish({ user: { baseURL: 'https://search.test/v1' } }) - - subject.actions().edit('baseURL', ' ') - await subject.save() - - expect(host.unset.mock.calls).toEqual([['baseURL']]) - }) - - it('writes the trimmed text of a text field', async () => { - const { host, subject } = form() - acceptWrites(host) - - subject.actions().edit('baseURL', ' https://other.test ') - await subject.save() - - expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']]) - }) - - it('keeps the drafts a save did not land, and reports the failure', async () => { - const { host, subject } = form() - - subject.actions().edit('timeoutMs', '9000') - await subject.save() - - // The stub Host accepted the call without storing it, exactly as a - // validator that refuses the value does. - expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000) - expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false }) - expect(subject.field('timeoutMs').text).toBe('9000') - }) - - it('reports a reset the Host did not apply as a failure', async () => { - const { host, subject } = form() - host.publish({ user: { timeoutMs: 9_000 } }) - - subject.actions().resetField('timeoutMs') - await subject.save() - - expect(host.unset).toHaveBeenCalledWith('timeoutMs') - expect(subject.shell().failed).toBe(true) - }) - - it('clears the failure as soon as the user edits again', async () => { - const { subject } = form() - - subject.actions().edit('timeoutMs', '9000') - await subject.save() - expect(subject.shell().failed).toBe(true) - - subject.actions().edit('timeoutMs', '9001') - - expect(subject.shell().failed).toBe(false) - }) - - it('discards every staged edit', async () => { - const { host, subject } = form() - - subject.actions().edit('timeoutMs', '9000') - subject.actions().discard() - - expect(subject.field('timeoutMs').text).toBe('60000') - expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) - - // A discard with nothing staged publishes nothing. - const before = subject.shell() - subject.actions().discard() - expect(subject.shell()).toEqual(before) - - await subject.save() - expect(host.set).not.toHaveBeenCalled() - }) - - it('refuses a second save while one is in flight', async () => { - const { host, subject } = form() - acceptWrites(host) - - subject.actions().edit('timeoutMs', '9000') - const first = subject.save() - expect(subject.shell().saving).toBe(true) - const second = subject.save() - await Promise.all([first, second]) - - expect(host.set).toHaveBeenCalledTimes(1) - }) - - it('publishes a projection whenever the scope or a draft changes', () => { - const { host, subject } = form() - const store = subject.bind(() => subject.field('timeoutMs').text) - expect(store.getSnapshot()).toBe('60000') - - host.publish({ value: { timeoutMs: 1_000 } }) - expect(store.getSnapshot()).toBe('1000') - - subject.actions().edit('timeoutMs', '2000') - expect(store.getSnapshot()).toBe('2000') - }) - - it('refuses to address a field the card never declared', () => { - const { subject } = form() - - expect(() => subject.field('nope')).toThrow('plugin card has no field nope') - }) - - it('renders an absent section value as an empty draft', () => { - const host = stubSettingsScope>() - const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')]) - - host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined }) - - expect(subject.field('timeoutMs').text).toBe('') - expect(subject.field('baseURL').text).toBe('') - expect(subject.shell().available).toBe(true) - }) - - it('stays unavailable while the namespace is not served', () => { - const host = stubSettingsScope>() - const subject = new CardForm(host.scope, [numberField('timeoutMs')]) - - host.publish({ status: 'unavailable' }) - - expect(subject.shell()).toMatchObject({ available: false, writable: false }) - }) -}) - -describe('BashCardController', () => { - it('projects both fields and saves them in one write pass', async () => { - const host = stubSettingsScope() - acceptWrites(host) - const controller = new BashCardController(host.scope) - host.publish({ - status: 'ready', - writable: true, - value: { timeoutMs: 5_000, maxOutputBytes: 64_000 }, - base: { timeoutMs: 60_000, maxOutputBytes: 64_000 }, - user: { timeoutMs: 5_000 }, - }) - const face = controller.inject() - - expect(face.hooks.bashCard.getSnapshot()).toMatchObject({ - available: true, - writable: true, - dirty: false, - timeoutMs: { text: '5000', overridden: true }, - maxOutputBytes: { text: '64000', overridden: false }, - }) - - face.edit('timeoutMs', '9000') - face.edit('maxOutputBytes', '1024') - expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true) - - face.save() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) - - expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]]) - expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false) - }) - - it('stages a reset and applies it on save', async () => { - const host = stubSettingsScope() - acceptWrites(host) - const controller = new BashCardController(host.scope) - host.publish({ - status: 'ready', - writable: true, - value: { timeoutMs: 5_000 }, - base: { timeoutMs: 60_000 }, - user: { timeoutMs: 5_000 }, - }) - const face = controller.inject() - - face.resetField('timeoutMs') - expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000') - - face.save() - await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') }) - - expect(face.hooks.bashCard.getSnapshot()).toMatchObject({ - dirty: false, - timeoutMs: { text: '60000', overridden: false }, - }) - }) - - it('discards staged edits without writing', () => { - const host = stubSettingsScope() - const controller = new BashCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} }) - const face = controller.inject() - - face.edit('timeoutMs', '9000') - face.discard() - - expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000') - expect(host.set).not.toHaveBeenCalled() - }) -}) - -describe('AgentLoopCardController', () => { - it('saves the only field it owns', async () => { - const host = stubSettingsScope() - acceptWrites(host) - const controller = new AgentLoopCardController(host.scope) - host.publish({ - status: 'ready', - writable: true, - value: { maxParallelToolCalls: 10 }, - base: { maxParallelToolCalls: 10 }, - user: {}, - }) - const face = controller.inject() - - face.edit('maxParallelToolCalls', '4') - face.save() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) }) - - expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({ - dirty: false, - maxParallelToolCalls: { text: '4', overridden: true }, - }) - }) - - it('reports a read-only document so the card can disable its controls', () => { - const host = stubSettingsScope() - const controller = new AgentLoopCardController(host.scope) - - host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } }) - - expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false) - }) -}) - describe('SubagentModelSelectionCardController', () => { it('joins stored routes with the live catalog without dropping unavailable choices', () => { const candidates = subagentModelCandidates( @@ -851,172 +492,6 @@ describe('SubagentModelSelectionCardController', () => { }) }) -describe('WebSearchCardController', () => { - it('reads the credential state for the reference the tab names', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - const state = () => controller.inject().hooks.webSearchCard.getSnapshot() - await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) - - host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) - await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) }) - - expect(state()).toMatchObject({ - baseURL: { text: 'https://search.test/v1', overridden: false }, - apiKey: { text: '', overridden: false }, - }) - }) - - it('writes the staged key through the credentials domain, never the settings section', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) - const face = controller.inject() - - face.edit('apiKey', ' ds-secret ') - expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true) - expect(credentials.set).not.toHaveBeenCalled() - - credentials.describe.mockImplementation(() => Promise.resolve({ - ok: true as const, - value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, - })) - face.save() - await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) - - expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret') - expect(host.set).not.toHaveBeenCalled() - await vi.waitFor(() => { - expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true }) - }) - }) - - it('keeps the stored key when the draft is left blank', () => { - const host = stubSettingsScope() - const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) - const face = controller.inject() - - face.edit('apiKey', ' ') - - expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false) - face.save() - - expect(credentials.set).not.toHaveBeenCalled() - }) - - it('re-reads when the Host reports the watched reference changed', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) - await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) - credentials.describe.mockClear() - - // Another reference is not this card's business. - controller.refreshCredential('OTHER_KEY') - expect(credentials.describe).not.toHaveBeenCalled() - - // A key written on another surface reaches this card only through this signal. - credentials.describe.mockImplementation(() => Promise.resolve({ - ok: true as const, - value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, - })) - controller.refreshCredential('DEEPSEEK_API_KEY') - - await vi.waitFor(() => { - expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true) - }) - }) - - it('addresses the reference the tab declares rather than the default', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} }) - const face = controller.inject() - - face.edit('apiKey', 'ds-secret') - face.save() - await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) - - expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret') - }) - - it('reports a key the Host did not store as a failed save', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) - const face = controller.inject() - - face.edit('apiKey', 'ds-secret') - face.save() - - await vi.waitFor(() => { - expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) - }) - }) - - it('keeps the card usable when the credential read is refused', async () => { - const host = stubSettingsScope() - const refusal = () => Promise.resolve({ - ok: false as const, - error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }), - }) - const describe = vi.fn(refusal) - const set = vi.fn(refusal) - const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } })) - const face = controller.inject() - await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) - - host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) - face.edit('apiKey', 'ds-secret') - face.save() - await vi.waitFor(() => { expect(set).toHaveBeenCalled() }) - - expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ - available: true, - apiKeyConfigured: false, - baseURL: { text: 'https://search.test/v1' }, - }) - }) - - it('ignores a credential read the Host refused', async () => { - const host = stubSettingsScope() - const describe = vi.fn(() => Promise.resolve({ - ok: false as const, - error: new RemoteError('gateway/internal', 'no credential provider', {}), - })) - const controller = new WebSearchCardController(host.scope, ctxWith({ - credentials: { describe, set: vi.fn() }, - })) - await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) - - expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false) - }) - - it('saves the endpoint and the search budget together', async () => { - const host = stubSettingsScope() - acceptWrites(host) - const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} }) - const face = controller.inject() - - face.edit('baseURL', 'https://other.test') - face.edit('maxUses', '3') - face.save() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) - - expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]]) - expect(credentials.set).not.toHaveBeenCalled() - }) -}) - describe('SubagentLimitsCardController', () => { it('validates staged limits, saves them, and restores composed defaults', async () => { const host = stubSettingsScope() diff --git a/packages/client/ui-settings-subagent/tsconfig.json b/packages/client/ui-settings-subagent/tsconfig.json new file mode 100644 index 0000000000..a43ca3698f --- /dev/null +++ b/packages/client/ui-settings-subagent/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-plugin-manager" + }, + { + "path": "../ui-settings" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-settings-subagent/tsdown.config.ts b/packages/client/ui-settings-subagent/tsdown.config.ts new file mode 100644 index 0000000000..f97175aba2 --- /dev/null +++ b/packages/client/ui-settings-subagent/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-subagent', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-web-search/README.i18n.yaml b/packages/client/ui-settings-web-search/README.i18n.yaml new file mode 100644 index 0000000000..051d811f82 --- /dev/null +++ b/packages/client/ui-settings-web-search/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-settings-web-search/README.md +README.md: d0e272cde281f07c0ff0dc278bba4a4f44ecec38 +README.zh.md: f3dbefa0a5a6107d9e1afac56dc9b8369048a584 diff --git a/packages/client/ui-settings-web-search/README.md b/packages/client/ui-settings-web-search/README.md new file mode 100644 index 0000000000..d0e272cde2 --- /dev/null +++ b/packages/client/ui-settings-web-search/README.md @@ -0,0 +1,79 @@ +--- +description: "The DeepSeek web-search provider's settings page on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-web-search + +English | [中文](README.zh.md) + +## Summary + +Open **Plugins** in the sidebar and select **Web search** in the Official group to set the provider's key, endpoint, and how many times one request may search. The page stages what is typed and writes it only on save; the key is written through the credentials domain rather than the settings document, so its literal never rides a response. The page exists while the Host serves the `web-search-deepseek` namespace. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The **Web search** card in the Official group opens the page. **API key** starts blank on every load and reports only whether a key is configured; a blank draft keeps the stored key, and the control is disabled when the credential cannot be written from here, such as a key the process environment supplies. **Endpoint** and **Max searches per request** render the effective value, carry an **Overridden** badge with **Reset to default** once overridden, and save as a reset when emptied. Nothing is written until **Save**; leaving the page drops the drafts. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `web-search-deepseek` namespace through `ctx.settingsScope` and keeps the staged form in `WebSearchCardController` over the shared `SettingsFormModel` of `ui-primitives`, with the key as the form's one secret control: its write goes to `remote.credentials.set` under the reference the section's `apiKeyEnv` names (`DEEPSEEK_API_KEY` when it names none), and success is read back from `remote.credentials.describe`. The controller re-reads the credential when the scope changes and when the Host reports `credentials/reference-updated` for the watched reference, since a key written on the Models page changes no settings section. The page registers `WebSearchCard` into the Plugins page's `plugins.item` slot through `ctx.settingsScope.whileServed`. + +
+ +----- + + +## Further Exploration + +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. +- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. +- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the page renders. +- [credentials](../../credentials/README.md) — the credential-reference seam the key writes through. +- [web-search-deepseek](../../web/web-search-deepseek/README.md) — the provider that registers the namespace. + +----- + + +## Model Experience + +None, as the package is a browser-side settings surface that registers no model surface. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **Three fields of the namespace** — the provider's model, API version, and token budget stay at their composed values; the page edits the key, the endpoint, and the search budget only. +- **Runtime invariant:** No companion is published. The page holds no owned relationship of its own: what it shows derives from the settings mirror and the credentials domain, and what it writes the Host validates. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-settings-web-search/README.zh.md b/packages/client/ui-settings-web-search/README.zh.md new file mode 100644 index 0000000000..f3dbefa0a5 --- /dev/null +++ b/packages/client/ui-settings-web-search/README.zh.md @@ -0,0 +1,79 @@ +--- +description: "dsh Web 客户端插件页上的 DeepSeek 网页搜索提供方设置页:API Key、接口地址与单次请求的搜索次数上限。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-web-search + +[English](README.md) | 中文 + +## 概述 + +在侧栏打开**插件**,在官方分组里选择**网页搜索**,即可设置提供方的密钥、接口地址,以及一次请求最多可以搜索几次。页面暂存输入、只在保存时写入;密钥通过凭据域写入而不进设置文件,其明文从不出现在任何响应里。页面只在 Host 服务 `web-search-deepseek` 命名空间期间存在。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +官方分组里的**网页搜索**卡片打开这一页。**API Key** 每次加载都是空的,只报告是否已配置密钥;留空保存等于保留现有密钥,而当凭据不能从这里写入时(例如由进程环境变量提供的密钥)该控件会被禁用。**接口地址**和**单次请求最多搜索次数**显示生效值,一旦被覆盖就带**已覆盖**标签和**恢复默认**,清空后保存等于重置。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.settingsScope` 绑定 `web-search-deepseek` 命名空间,用 `ui-primitives` 的共享 `SettingsFormModel` 在 `WebSearchCardController` 里维护暂存表单,密钥是表单里唯一的密文控件:写入走 `remote.credentials.set`,引用名取自本节的 `apiKeyEnv`(未指定时为 `DEEPSEEK_API_KEY`),是否成功由 `remote.credentials.describe` 回读判定。scope 变化时、以及 Host 对所监视引用发出 `credentials/reference-updated` 时,控制器都会重读凭据,因为在模型页写入的密钥不会改变任何设置节。页面通过 `ctx.settingsScope.whileServed` 把 `WebSearchCard` 注册进插件页的 `plugins.item` slot。 + +
+ +----- + + +## 进一步探索 + +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 +- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 +- [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。 +- [credentials](../../credentials/README.zh.md)——密钥写入所经的凭据引用 seam。 +- [web-search-deepseek](../../web/web-search-deepseek/README.zh.md)——注册该命名空间的提供方。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的设置界面,不注册任何模型面。 + +#### KV 缓存影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **只编辑命名空间的三个字段**——提供方的模型、API 版本和 token 预算保持组合值不变;本页只编辑密钥、接口地址和搜索次数。 +- **运行时不变量:**不发布伴生。本页没有自己拥有的关系:它显示的内容派生自设置镜像与凭据域,它写入的内容由 Host 校验。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-settings-web-search/package.json b/packages/client/ui-settings-web-search/package.json new file mode 100644 index 0000000000..4959b285bc --- /dev/null +++ b/packages/client/ui-settings-web-search/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-web-search", + "description": "Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-web-search" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx b/packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx similarity index 69% rename from packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx rename to packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx index 42fedcf685..1d56b46bb1 100644 --- a/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx +++ b/packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx @@ -1,42 +1,37 @@ /** - * The web-search provider's configuration page: its endpoint, its per-request + * The web-search provider's settings page: its endpoint, its per-request * search budget, and the key — which is written through the credentials * domain, never into the settings section, so the literal never rides a response. */ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import { SettingsForm, SettingsSecretField, SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { SecretField, ValueField } from './fields.tsx' -import { PluginConfigForm } from './PluginConfigForm.tsx' +import { formLabels } from './locales.ts' import type { WebSearchCardFace } from './web-search-card-controller.ts' /** Props the renderer binds for the web-search page. */ export type WebSearchCardProps = PropsRuntime<'plugins.item'> - & PropsLocale<'settings.plugins'> + & PropsLocale<'settings.webSearch'> & InjectFace /** - * Render the web-search provider's one-liner or its configuration form, as the Plugins page asks. + * Render the web-search provider's one-liner or its settings form, as the Plugins page asks. * @param props - the view asked for, locale copy, the form snapshot, and its actions. * @returns the one-liner, or the form. */ export function WebSearchCard(props: WebSearchCardProps) { const { t } = props const state = props.useWebSearchCard(snapshot => snapshot) - if (props.view === 'summary') return t('webSearchDescription') + if (props.view === 'summary') return t('description') const disabled = !state.writable return ( - - + { props.edit('apiKey', text) }} /> - { props.edit('baseURL', text) }} onReset={() => { props.resetField('baseURL') }} /> - { props.edit('maxUses', text) }} onReset={() => { props.resetField('maxUses') }} /> - + ) } diff --git a/packages/client/ui-settings-web-search/src/client/index.ts b/packages/client/ui-settings-web-search/src/client/index.ts new file mode 100644 index 0000000000..f0d90eab0f --- /dev/null +++ b/packages/client/ui-settings-web-search/src/client/index.ts @@ -0,0 +1,59 @@ +/** + * The web-search provider's settings page, browser half: the key, the + * endpoint, and the per-request search budget over the `web-search-deepseek` + * namespace the provider registers. The page registers into the Plugins + * page's `plugins.item` slot while the Host serves that namespace, so a + * deployment without the provider shows no trace of it. + */ + +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remote Context merge and the forwarded-event key face. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { WebSearchCard } from './WebSearchCard.tsx' +import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' +import { en, zh, type WebSearchSettingsLocaleKey } from './locales.ts' + +export type { WebSearchCardProps } from './WebSearchCard.tsx' +export type { WebSearchCardFace, WebSearchCardState, WebSearchSettings } from './web-search-card-controller.ts' +export type { WebSearchSettingsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Web-search settings page copy. */ + 'settings.webSearch': WebSearchSettingsLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.webSearch' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'] + +/** + * Mount the web-search settings page while the Host serves its namespace. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-web-search: dictionaries') + const card = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx) + // The credential the page reports is not part of any settings section, so + // its scope publishes nothing when one is written. This is the only signal + // that a key written on another surface reached the Host. + ctx.effect( + () => ctx.remote.$on('credentials/reference-updated', (ref) => { card.refreshCredential(ref) }), + 'ui-settings-web-search: credential invalidations', + ) + ctx.effect(() => ctx.settingsScope.whileServed([WEB_SEARCH_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'web-search', order: 40, label: () => t('title'), locale: NS, inject: () => card.inject(), + }, WebSearchCard))), 'ui-settings-web-search: page') +} diff --git a/packages/client/ui-settings-web-search/src/client/locales.ts b/packages/client/ui-settings-web-search/src/client/locales.ts new file mode 100644 index 0000000000..4df1f4bb6e --- /dev/null +++ b/packages/client/ui-settings-web-search/src/client/locales.ts @@ -0,0 +1,64 @@ +/** Locale bundles for the web-search provider's settings page. */ + +import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Locale keys the page renders. */ +export type WebSearchSettingsLocaleKey = + | 'title' | 'description' + | 'apiKey' | 'apiKeyHint' | 'apiKeySet' | 'apiKeyUnset' + | 'baseUrl' | 'baseUrlHint' | 'maxUses' | 'maxUsesHint' + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' + +/** English copy. */ +export const en: Record = { + title: 'Web search', + description: 'The DeepSeek search provider.', + apiKey: 'API key', + apiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', + apiKeySet: 'A key is configured.', + apiKeyUnset: 'No key is configured; search is unavailable until one is.', + baseUrl: 'Endpoint', + baseUrlHint: 'Leave blank to use the provider default.', + maxUses: 'Max searches per request', + maxUsesHint: 'How many times one request may search before it must answer.', + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + save: 'Save', + saving: 'Saving…', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + invalidNumber: 'Enter a number, or leave blank to use the default.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + title: '网页搜索', + description: 'DeepSeek 搜索提供方。', + apiKey: 'API Key', + apiKeyHint: '不写入设置文件。留空表示保持当前密钥。', + apiKeySet: '已配置密钥。', + apiKeyUnset: '未配置密钥;配置之前搜索不可用。', + baseUrl: '接口地址', + baseUrlHint: '留空则使用提供方默认地址。', + maxUses: '单次请求最多搜索次数', + maxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + unavailable: '该插件当前未加载,暂时无法配置。', + save: '保存', + saving: '保存中…', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + invalidNumber: '请填数字;留空表示使用默认值。', +} + +/** + * The form frame's copy, read from this page's dictionary. + * @param t - the page's locale reader. + * @returns the labels the shared settings form renders. + */ +export function formLabels(t: (key: WebSearchSettingsLocaleKey) => string): SettingsFormLabels { + return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') } +} diff --git a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts b/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts similarity index 83% rename from packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts rename to packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts index 35990763bb..51aaf3c29e 100644 --- a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts @@ -1,12 +1,12 @@ /** - * The web-search card's staged form over the `web-search-deepseek` settings + * The web-search page's staged form over the `web-search-deepseek` settings * namespace. * * The key is the one control that does not live in the section: its literal - * never rides a response, so the card learns only whether one is configured + * never rides a response, so the page learns only whether one is configured * and writes it through the credentials domain, addressed by the reference the * section names. It is still staged with the rest of the form, so one save - * covers everything the card shows. + * covers everything the page shows. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' @@ -15,9 +15,9 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { - CardForm, numberField, textField, - type CardActions, type CardFieldState, type CardShell, -} from './card-form.ts' + SettingsFormModel, settingsNumberField, settingsTextField, + type SettingsFieldState, type SettingsFormActions, type SettingsFormShell, +} from '@deepseek-ai/dsh-client-ui-primitives' /** * Namespace of the DeepSeek search provider. Spelled here rather than @@ -31,7 +31,7 @@ const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' /** Form field the credential control stages under. */ const API_KEY_FIELD = 'apiKey' -/** The search-provider fields this card edits. */ +/** The search-provider fields this page edits. */ export interface WebSearchSettings { /** Credential reference naming the environment key. */ apiKeyEnv?: string @@ -51,46 +51,46 @@ interface CredentialState { writable: boolean } -/** What the web-search card renders. */ -export interface WebSearchCardState extends CardShell { +/** What the web-search page renders. */ +export interface WebSearchCardState extends SettingsFormShell { /** Provider endpoint. */ - baseURL: CardFieldState + baseURL: SettingsFieldState /** Searches allowed per request. */ - maxUses: CardFieldState + maxUses: SettingsFieldState /** The staged credential, which starts blank on every load. */ - apiKey: CardFieldState + apiKey: SettingsFieldState /** Whether the Host reports a credential configured for the referenced key. */ apiKeyConfigured: boolean /** Whether the credentials domain accepts a write for it; false disables the control. */ apiKeyWritable: boolean } -/** The registration-side face the web-search card's slot entry injects. */ -export interface WebSearchCardFace extends CardActions { +/** The registration-side face the web-search page's slot entry injects. */ +export interface WebSearchCardFace extends SettingsFormActions { hooks: { - /** Card snapshot bound by the renderer as useWebSearchCard. */ + /** Page snapshot bound by the renderer as useWebSearchCard. */ webSearchCard: SnapshotStore } } -/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */ +/** Bridges the `web-search-deepseek` scope and the credentials domain onto the page. */ export class WebSearchCardController { - private readonly form: CardForm + private readonly form: SettingsFormModel private readonly store: SnapshotStore private credential: CredentialState = { ref: '', configured: false, writable: true } /** * @param scope - the bound settings scope for the `web-search-deepseek` namespace. - * @param ctx - the card plugin's context, whose `remote.credentials` namespace + * @param ctx - the page plugin's context, whose `remote.credentials` namespace * answers for the credential the section references. */ constructor( private readonly scope: SettingsScope, private readonly ctx: ClientContext, ) { - this.form = new CardForm( + this.form = new SettingsFormModel( scope, - [textField('baseURL'), numberField('maxUses')], + [settingsTextField('baseURL'), settingsNumberField('maxUses')], [{ field: API_KEY_FIELD, write: text => this.writeKey(text) }], ) this.store = this.form.bind(() => this.projection()) @@ -132,7 +132,7 @@ export class WebSearchCardController { ref, configured: view?.configured ?? false, // An unknown reference is treated as writable: the control stays usable - // and the Host is what refuses, rather than the card guessing a refusal. + // and the Host is what refuses, rather than the page guessing a refusal. writable: view?.writable ?? true, } if (next.configured === this.credential.configured && next.writable === this.credential.writable) return @@ -141,7 +141,7 @@ export class WebSearchCardController { } /** - * Re-read after the Host reports a change to the reference this card watches. + * Re-read after the Host reports a change to the reference this page watches. * * A key can be written from somewhere else — the Models page addresses the * same reference — and the settings section does not change when it is, so @@ -154,8 +154,8 @@ export class WebSearchCardController { } /** - * Build the face the card's slot registration injects. - * @returns the card's snapshot and its form actions. + * Build the face the page's slot registration injects. + * @returns the page's snapshot and its form actions. */ inject(): WebSearchCardFace { return { hooks: { webSearchCard: this.store }, ...this.form.actions() } diff --git a/packages/client/ui-settings-web-search/src/css-modules.d.ts b/packages/client/ui-settings-web-search/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-settings-web-search/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-settings-web-search/src/index.ts b/packages/client/ui-settings-web-search/src/index.ts new file mode 100644 index 0000000000..d3b9035057 --- /dev/null +++ b/packages/client/ui-settings-web-search/src/index.ts @@ -0,0 +1,11 @@ +/** + * Web-search settings page, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half owns the page + * through exports["./client"], discovered from the package.json dsh.client + * declaration. The `web-search-deepseek` namespace the page edits is + * registered by the search provider, so this package registers no namespace + * of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-web-search/tests/apply.client.spec.ts b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts new file mode 100644 index 0000000000..bdff8383fe --- /dev/null +++ b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts @@ -0,0 +1,109 @@ +/** What the browser half registers, when, which credential it watches, and that it all leaves with the fiber. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject, NS } from '../src/client/index.ts' +import type { WebSearchCardFace } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' + +/** One Host view of a served namespace. */ +function view(ns: string, revision = 0) { + return { ns, schema: {}, value: {}, applies: 'live', secrets: [], revision } +} + +/** @param served - namespaces the Host describes; omitted answers a failed read. */ +async function bench(served?: string[]) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + locale.setLocale('zh') + ctx.provide('locale', locale) + const describeCredentials = vi.fn(() => Promise.resolve({ + ok: false, error: new RemoteError('gateway/internal', 'no provider', {}), + })) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } + : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const remote = new TestRemote(ctx, { + credentials: { describe: describeCredentials, set: vi.fn() }, + settings: { describe: describeSettings }, + }) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } +} + +/** The Plugins page's item slot, as its owner declares it. */ +function declareRoot(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'plugins.item': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-web-search apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']) + }) + + it('registers the page while the Host serves the namespace, titled in the active locale', async () => { + const { ctx, slots } = await bench(['web-search-deepseek']) + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + const entry = slots.entries('plugins.item')[0]! + expect(entry.options).toMatchObject({ id: 'web-search', order: 40 }) + expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索') + expect(entry.locale).toBe(NS) + const face = (entry.inject as unknown as () => WebSearchCardFace)() + expect(Object.keys(face.hooks)).toEqual(['webSearchCard']) + }) + + it('registers nothing while the namespace is not served', async () => { + const { ctx, slots, describeSettings } = await bench(['shell']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) + + it('re-reads the credential when the Host reports the watched reference changed, and ignores another', async () => { + const { ctx, slots, describeCredentials, remote } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) + describeCredentials.mockClear() + + remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY']) + await Promise.resolve() + expect(describeCredentials).not.toHaveBeenCalled() + + // A key written on another surface changes no settings section, so this + // event is the only thing that reaches the page. + remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) + }) + + it('collapses the page on teardown', async () => { + const { ctx, slots } = await bench(['web-search-deepseek']) + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + await fiber.dispose() + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-settings-web-search/tests/card.client.spec.tsx b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx new file mode 100644 index 0000000000..7f0c480cf6 --- /dev/null +++ b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +/** The web-search page as the Plugins page renders it: its key control, its two fields, and their resets. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' +import { WebSearchCard, type WebSearchCardProps } from '../src/client/WebSearchCard.tsx' +import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + +function field(text: string, rest: Partial = {}): SettingsFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +function cardActions() { + return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } +} + +describe('WebSearchCard', () => { + function renderWebSearch(state: Partial = {}) { + const store = createSnapshotStore({ + ...settled, + baseURL: field(''), + maxUses: field('5'), + apiKey: field(''), + apiKeyConfigured: false, + apiKeyWritable: true, + ...state, + }) + const actions = cardActions() + const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps + render() + return actions + } + + it('renders its one-liner alone in the summary view', () => { + const store = createSnapshotStore({ + ...settled, baseURL: field(''), maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true, + }) + const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps + render() + + expect(document.body.textContent).toBe(en.description) + expect(screen.queryByLabelText(en.apiKey)).toBeNull() + }) + + it('reports whether a key is configured without ever showing one', () => { + renderWebSearch({ apiKeyConfigured: true }) + + expect(screen.getByText(en.apiKeySet)).toBeTruthy() + expect(screen.getByLabelText(en.apiKey)).toHaveProperty('type', 'password') + }) + + it('keeps the key control usable while the settings document is read-only', () => { + const actions = renderWebSearch({ writable: false }) + + const key = screen.getByLabelText(en.apiKey) + expect(key).toHaveProperty('disabled', false) + expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', true) + + fireEvent.change(key, { target: { value: 'ds-secret' } }) + + expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') + }) + + it('disables the key control when the reference itself is not writable', () => { + // A key coming from the process environment: the settings document is + // writable, the credential is not. + renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) + + expect(screen.getByLabelText(en.apiKey)).toHaveProperty('disabled', true) + expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', false) + }) + + it('stages the endpoint, the search budget, and their resets', () => { + const actions = renderWebSearch({ + baseURL: field('https://search.test/v1', { overridden: true }), + maxUses: field('3', { overridden: true }), + }) + + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://other.test' } }) + fireEvent.change(screen.getByLabelText(en.maxUses), { target: { value: '4' } }) + const resets = screen.getAllByRole('button', { name: en.reset }) + expect(resets).toHaveLength(2) + for (const reset of resets) fireEvent.click(reset) + + expect(actions.edit.mock.calls).toEqual([ + ['baseURL', 'https://other.test'], + ['maxUses', '4'], + ]) + expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']]) + }) +}) diff --git a/packages/client/ui-settings-web-search/tests/controller.client.spec.ts b/packages/client/ui-settings-web-search/tests/controller.client.spec.ts new file mode 100644 index 0000000000..8385f2b39d --- /dev/null +++ b/packages/client/ui-settings-web-search/tests/controller.client.spec.ts @@ -0,0 +1,202 @@ +/** + * The web-search page's form over a scripted settings scope and credentials + * domain: what it projects, where the key goes, and what a save writes. + */ + +import { describe, expect, it, vi } from 'vitest' +import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' + +/** Make the stub behave like a Host that accepts every write. */ +function acceptWrites(host: StubSettingsScope): void { + const section = (): Record => ({ ...host.scope.getSnapshot().value as object }) + const layer = (): Record => ({ ...host.scope.getSnapshot().user as object }) + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) + }) + host.unset.mockImplementation((field: string) => { + const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) + const base = host.scope.getSnapshot().base as Record | undefined + host.publish({ value: { ...section(), [field]: base?.[field] } as T, user }) + }) +} + +/** The page plugin's context, scripted down to the namespaces the page reaches. */ +function ctxWith(namespaces: object) { + return { remote: namespaces } as never +} + +function credentialsApi(configured: boolean) { + const describe = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured, writable: true } }, + })) + const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) + return { ctx: ctxWith({ credentials: { describe, set } }), describe, set } +} + +describe('WebSearchCardController', () => { + it('reads the credential state for the reference the tab names', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + const state = () => controller.inject().hooks.webSearchCard.getSnapshot() + await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) + await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) }) + + expect(state()).toMatchObject({ + baseURL: { text: 'https://search.test/v1', overridden: false }, + apiKey: { text: '', overridden: false }, + }) + }) + + it('writes the staged key through the credentials domain, never the settings section', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() + + face.edit('apiKey', ' ds-secret ') + expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true) + expect(credentials.set).not.toHaveBeenCalled() + + credentials.describe.mockImplementation(() => Promise.resolve({ + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, + })) + face.save() + await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) + + expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret') + expect(host.set).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true }) + }) + }) + + it('keeps the stored key when the draft is left blank', () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() + + face.edit('apiKey', ' ') + + expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false) + face.save() + + expect(credentials.set).not.toHaveBeenCalled() + }) + + it('re-reads when the Host reports the watched reference changed', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) + credentials.describe.mockClear() + + // Another reference is not this card's business. + controller.refreshCredential('OTHER_KEY') + expect(credentials.describe).not.toHaveBeenCalled() + + // A key written on another surface reaches this card only through this signal. + credentials.describe.mockImplementation(() => Promise.resolve({ + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, + })) + controller.refreshCredential('DEEPSEEK_API_KEY') + + await vi.waitFor(() => { + expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true) + }) + }) + + it('addresses the reference the tab declares rather than the default', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} }) + const face = controller.inject() + + face.edit('apiKey', 'ds-secret') + face.save() + await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) + + expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret') + }) + + it('reports a key the Host did not store as a failed save', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() + + face.edit('apiKey', 'ds-secret') + face.save() + + await vi.waitFor(() => { + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) + }) + }) + + it('keeps the card usable when the credential read is refused', async () => { + const host = stubSettingsScope() + const refusal = () => Promise.resolve({ + ok: false as const, + error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }), + }) + const describe = vi.fn(refusal) + const set = vi.fn(refusal) + const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } })) + const face = controller.inject() + await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) + face.edit('apiKey', 'ds-secret') + face.save() + await vi.waitFor(() => { expect(set).toHaveBeenCalled() }) + + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ + available: true, + apiKeyConfigured: false, + baseURL: { text: 'https://search.test/v1' }, + }) + }) + + it('ignores a credential read the Host refused', async () => { + const host = stubSettingsScope() + const describe = vi.fn(() => Promise.resolve({ + ok: false as const, + error: new RemoteError('gateway/internal', 'no credential provider', {}), + })) + const controller = new WebSearchCardController(host.scope, ctxWith({ + credentials: { describe, set: vi.fn() }, + })) + await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + + expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false) + }) + + it('saves the endpoint and the search budget together', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} }) + const face = controller.inject() + + face.edit('baseURL', 'https://other.test') + face.edit('maxUses', '3') + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) + + expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]]) + expect(credentials.set).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-settings-web-search/tsconfig.json b/packages/client/ui-settings-web-search/tsconfig.json new file mode 100644 index 0000000000..a43ca3698f --- /dev/null +++ b/packages/client/ui-settings-web-search/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-plugin-manager" + }, + { + "path": "../ui-settings" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-settings-web-search/tsdown.config.ts b/packages/client/ui-settings-web-search/tsdown.config.ts new file mode 100644 index 0000000000..112d2e7f96 --- /dev/null +++ b/packages/client/ui-settings-web-search/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-web-search', ['lib/types/index.js']) diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index e795ef4afc..2dec36af4e 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -296,4 +296,35 @@ export class SettingsScopeBinder extends Service { }, `ui-settings: ${spec.namespace} settings scope`) return controller } + + /** + * Keep a registration alive while the Host serves any of some namespaces: + * `register` runs once one of them is in the describe mirror, and its + * disposer runs when none is or when the returned disposer runs. A plugin + * whose page edits a namespace another plugin owns registers the page + * through this, so a deployment that never composed the owner shows no + * trace of the page. + * @param namespaces - the settings namespaces the registration follows. + * @param register - registers the contribution; returns its disposer. + * @returns the disposer ending the watch and any live registration. + */ + whileServed(namespaces: readonly string[], register: () => () => void): () => void { + let off: (() => void) | undefined + const sync = (): void => { + const served = this.mirror.getSnapshot().view?.namespaces.some(view => namespaces.includes(view.ns)) ?? false + if (served && off === undefined) off = register() + else if (!served && off !== undefined) { + off() + off = undefined + } + } + const unsubscribe = this.mirror.subscribe(sync) + void this.mirror.ensure() + sync() + return () => { + unsubscribe() + off?.() + off = undefined + } + } } diff --git a/packages/client/ui-settings/tests/while-served.client.spec.ts b/packages/client/ui-settings/tests/while-served.client.spec.ts new file mode 100644 index 0000000000..4311314d38 --- /dev/null +++ b/packages/client/ui-settings/tests/while-served.client.spec.ts @@ -0,0 +1,94 @@ +/** The served-namespace watch: a registration that lives exactly while the Host serves a namespace. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { SettingsSchemaService } from '../src/client/schema.ts' +import { SettingsScopeBinder } from '../src/client/settings-scope.ts' +import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts' + +/** What the Host describes: the named namespaces, each an empty section. */ +function described(namespaces: readonly string[], revision = 0) { + return { + ok: true as const, + value: { + writable: true, + hasDocument: true, + namespaces: namespaces.map(ns => ({ ns, schema: {}, value: {}, applies: 'live', secrets: [], revision })), + }, + } +} + +/** A binder over a scripted Host, and the plugin context that will watch through it. */ +async function bench(namespaces: readonly string[]) { + const describeCall = vi.fn().mockResolvedValue(described(namespaces)) + const ctx = new Context() + new TestRemote(ctx, { settings: { describe: describeCall } }) + const mirror = new SettingsDescribeMirror({ remote: { settings: { describe: describeCall } } } as never) + await ctx.plugin(SettingsScopeBinder, { mirror, schema: new SettingsSchemaService(ctx), persistence: 'host' }).await() + return { ctx, mirror, describeCall } +} + +describe('SettingsScopeBinder.whileServed', () => { + it('registers once any watched namespace is served, and withdraws when none is', async () => { + const { ctx, mirror, describeCall } = await bench(['agent-loop']) + const dispose = vi.fn() + const register = vi.fn(() => dispose) + let off!: () => void + const fiber = ctx.plugin({ + inject: ['settingsScope'], + apply: (plugin: Context) => { + off = plugin.settingsScope.whileServed(['shell', 'subagent'], register) + }, + }) + await fiber.await() + await mirror.ensure() + expect(register).not.toHaveBeenCalled() + + describeCall.mockResolvedValue(described(['agent-loop', 'subagent'], 1)) + await mirror.load() + expect(register).toHaveBeenCalledOnce() + // Still served: a further read changes nothing. + await mirror.load() + expect(register).toHaveBeenCalledOnce() + expect(dispose).not.toHaveBeenCalled() + + describeCall.mockResolvedValue(described(['agent-loop'], 2)) + await mirror.load() + expect(dispose).toHaveBeenCalledOnce() + + describeCall.mockResolvedValue(described(['shell'], 3)) + await mirror.load() + expect(register).toHaveBeenCalledTimes(2) + + off() + expect(dispose).toHaveBeenCalledTimes(2) + // Ended: the watch no longer follows the Host. + describeCall.mockResolvedValue(described([], 4)) + await mirror.load() + describeCall.mockResolvedValue(described(['shell'], 5)) + await mirror.load() + expect(register).toHaveBeenCalledTimes(2) + expect(dispose).toHaveBeenCalledTimes(2) + await fiber.dispose() + }) + + it('registers at once for a namespace the mirror already holds, and ending the watch with nothing live is harmless', async () => { + const { ctx, mirror } = await bench(['shell']) + await mirror.ensure() + const dispose = vi.fn() + let off!: () => void + await ctx.plugin({ + inject: ['settingsScope'], + apply: (plugin: Context) => { + off = plugin.settingsScope.whileServed(['shell'], () => dispose) + }, + }).await() + + expect(dispose).not.toHaveBeenCalled() + off() + expect(dispose).toHaveBeenCalledOnce() + off() + expect(dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99e2ff0442..8a9475984e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2169,6 +2169,9 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-agent-loop': + specifier: workspace:^ + version: link:../../client/ui-settings-agent-loop '@deepseek-ai/dsh-client-ui-settings-general': specifier: workspace:^ version: link:../../client/ui-settings-general @@ -2181,9 +2184,18 @@ importers: '@deepseek-ai/dsh-client-ui-settings-plugins': specifier: workspace:^ version: link:../../client/ui-settings-plugins + '@deepseek-ai/dsh-client-ui-settings-shell': + specifier: workspace:^ + version: link:../../client/ui-settings-shell + '@deepseek-ai/dsh-client-ui-settings-subagent': + specifier: workspace:^ + version: link:../../client/ui-settings-subagent '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions': specifier: workspace:^ version: link:../../client/ui-settings-unarchive-sessions + '@deepseek-ai/dsh-client-ui-settings-web-search': + specifier: workspace:^ + version: link:../../client/ui-settings-web-search '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar @@ -3746,6 +3758,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 @@ -4014,6 +4029,45 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-settings-agent-loop: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-settings-general: dependencies: '@deepseek-ai/schemastery': @@ -4166,6 +4220,72 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-settings-plugins: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-shell: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-subagent: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4200,9 +4320,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -4258,6 +4375,45 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-settings-web-search: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-sidebar: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index a3c030bc0b..ed2fec5409 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -116,6 +116,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/extensions/ui-cordis': { kind: 'indirect', reason: 'The definition card drives the host dynamic run/stop verbs that the model\'s cordis_run/cordis_stop tools also reach; the runner owns any model-visible effect.' }, 'packages/client/ui-permission-presets': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' }, 'packages/client/ui-settings-plugins': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-shell': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-agent-loop': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-subagent': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-web-search': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, 'packages/client/ui-user-questions': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 6ec529b39d..9ea5d8d206 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -242,6 +242,10 @@ "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-experimental-client-ui-agent-team": ["./packages/experimental/client-ui-agent-team/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], + "@deepseek-ai/dsh-client-ui-settings-shell": ["./packages/client/ui-settings-shell/src"], + "@deepseek-ai/dsh-client-ui-settings-agent-loop": ["./packages/client/ui-settings-agent-loop/src"], + "@deepseek-ai/dsh-client-ui-settings-subagent": ["./packages/client/ui-settings-subagent/src"], + "@deepseek-ai/dsh-client-ui-settings-web-search": ["./packages/client/ui-settings-web-search/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], "@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"], "@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b830d85b2f..7b0982ba51 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -104,6 +104,10 @@ { "path": "./packages/client/ui-permission-presets" }, { "path": "./packages/client/ui-plan" }, { "path": "./packages/client/ui-settings-plugins" }, + { "path": "./packages/client/ui-settings-shell" }, + { "path": "./packages/client/ui-settings-agent-loop" }, + { "path": "./packages/client/ui-settings-subagent" }, + { "path": "./packages/client/ui-settings-web-search" }, { "path": "./packages/client/ui-user-questions" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/session-query/session-log-export/tsconfig.client.json" }, From caa0c99be17c3a0c981d25a8fb04441e83e2b232 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 20:13:26 +0800 Subject: [PATCH 003/121] docs(web): follow up the review on the companion move and detail slots The maintained docs now name the four companion packages where they still credited `ui-settings-plugins`: the web-app row comment, the Plugins page README, the ui-settings README, and the client group README, which gains a row per companion. The ui-settings README states the `whileServed` lifetime and disposer ownership beside `bind`, and the JSDoc says the caller owns the disposer. The settings-page cookbook keeps the one home of the lazy-CJS build limitation for packages outside this repository, its JSX example is a tsx fence, and the architecture index and website label follow the page's title. The Agent Note records the shell page's `plugins.item` id, the two companions that use `ctx.remote` list api-remotes in `dsh.client.inject`, and the moved form model test speaks of the form. --- ...-09-17-settings-pages-as-companion-packages.i18n.yaml | 4 ++-- .../2026-09-17-settings-pages-as-companion-packages.md | 1 + ...2026-09-17-settings-pages-as-companion-packages.zh.md | 1 + docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cookbook/adding-a-settings-card.i18n.yaml | 4 ++-- docs/cookbook/adding-a-settings-card.md | 4 +++- docs/cookbook/adding-a-settings-card.zh.md | 4 +++- packages/bundle/web-app/cordis.patch.yml | 5 +++-- packages/client/README.i18n.yaml | 4 ++-- packages/client/README.md | 6 +++++- packages/client/README.zh.md | 6 +++++- packages/client/ui-plugin-manager/README.i18n.yaml | 4 ++-- packages/client/ui-plugin-manager/README.md | 4 ++-- packages/client/ui-plugin-manager/README.zh.md | 4 ++-- .../tests/settings-form-model.client.spec.ts | 2 +- packages/client/ui-settings-subagent/package.json | 3 ++- packages/client/ui-settings-web-search/package.json | 3 ++- packages/client/ui-settings/README.i18n.yaml | 4 ++-- packages/client/ui-settings/README.md | 9 +++++++-- packages/client/ui-settings/README.zh.md | 9 +++++++-- packages/client/ui-settings/src/client/settings-scope.ts | 4 +++- website/docs.ts | 2 +- 24 files changed, 62 insertions(+), 33 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml index 0dd8337ca2..665be1daa4 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md -2026-09-17-settings-pages-as-companion-packages.md: dc604c010787ee35522b351b643fb2691540b4f9 -2026-09-17-settings-pages-as-companion-packages.zh.md: 066b17c9760f381089a6a48b1ec6606e600531d3 +2026-09-17-settings-pages-as-companion-packages.md: de2f2897938fcc158c49bed772f399b4d9328d19 +2026-09-17-settings-pages-as-companion-packages.zh.md: 27d00faa156f0f48c6dc3995930be9ca629a732d diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md index dc604c0107..de2f289793 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md @@ -33,6 +33,7 @@ The four official settings pages — shell, agent loop, Subagent, web search — - The Plugins page's Official group lists the same four pages in the same order, from four packages instead of one; their labels and forms are unchanged, so the page goldens hold. - The cookbook [adding a settings page](../../../../docs/cookbook/adding-a-settings-card.md) shows the community path and names the companion template. - The client module system's rule stands: a browser half rides the Loader row whose specifier is the bare package name, so a bundle split into subpath rows keeps every page it registers on its root row. +- The shell page's `plugins.item` id is `shell`, where the old package registered `bash`: `subject.id` on an official page is now a discriminator `plugins.detail.*` contributions key on, so the id names the capability the page edits rather than one executor family. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md index 066b17c976..27d00faa15 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md @@ -33,6 +33,7 @@ - 插件页官方分组列出同样四个页面、同样顺序,来自四个包而不是一个;标签与表单未变,页面 golden 保持不变。 - 手册[添加设置页](../../../../docs/cookbook/adding-a-settings-card.zh.md)展示社区路径并点名伴生包模板。 - 客户端模块系统的规则不变:浏览器半侧挂在说明符为裸包名的 Loader 行上,因此拆成子路径行的组合包,其注册的所有页面都留在根行上。 +- 终端页在 `plugins.item` 里的 id 是 `shell`,旧包注册的是 `bash`:官方页面上的 `subject.id` 现在是 `plugins.detail.*` 贡献可以据以判断的标识,所以 id 以页面编辑的能力命名,而不是某一个执行器家族。 ## 测试 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ead9c04e54..63e1078eaa 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 37aaf83e37fb5cb7e3df4bd6ed034ccd42103919 -architecture.zh.md: 084fa76a045a9ae744228e8e43900f174b26ae9e +architecture.md: ebb28a79fb17ff1364b59dddfaeb5b6eb723bd1f +architecture.zh.md: 0a19671c6bb36df689585e8f5654ce721834bd49 diff --git a/docs/architecture.md b/docs/architecture.md index 37aaf83e37..ebb28a79fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,4 +161,4 @@ New behavior attaches to a documented extension point. Changing the loop itself | Store sessions in a new backend | implement `SessionPersistence` (`create`/`open`/`stat`/`list`/`export`) over the shared handle scaffolding | | Scope a registration to one agent | use that agent's `agent.ctx` | -The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [settings cards](cookbook/adding-a-settings-card.md). The [Conversation subsystem](subsystems/conversation.md) owns Chat-node assembly. +The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [settings pages](cookbook/adding-a-settings-card.md). The [Conversation subsystem](subsystems/conversation.md) owns Chat-node assembly. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 084fa76a04..0a19671c6b 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -165,4 +165,4 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 在新后端存储会话 | 基于共享的句柄脚手架实现 `SessionPersistence`(`create`/`open`/`stat`/`list`/`export`) | | 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx` | -[扩展实操手册](cookbook/extension-cookbook.zh.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.zh.md)、[工具](cookbook/adding-a-tool.zh.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.zh.md)和[设置卡片](cookbook/adding-a-settings-card.zh.md)的分步指南。[Conversation 子系统](subsystems/conversation.zh.md)负责 Chat node 组装。 +[扩展实操手册](cookbook/extension-cookbook.zh.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.zh.md)、[工具](cookbook/adding-a-tool.zh.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.zh.md)和[设置页](cookbook/adding-a-settings-card.zh.md)的分步指南。[Conversation 子系统](subsystems/conversation.zh.md)负责 Chat node 组装。 diff --git a/docs/cookbook/adding-a-settings-card.i18n.yaml b/docs/cookbook/adding-a-settings-card.i18n.yaml index d04ea95824..1c493f63fe 100644 --- a/docs/cookbook/adding-a-settings-card.i18n.yaml +++ b/docs/cookbook/adding-a-settings-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-settings-card.md -adding-a-settings-card.md: cc8daa90bcc3a757cd41dbcaecb0eab662d1dba8 -adding-a-settings-card.zh.md: a0fce4075b9e29630c772bc52e07cd00d89d5bab +adding-a-settings-card.md: 72d7eaeb85d0d4f6981e814a8c335428c561b6bd +adding-a-settings-card.zh.md: d1254078085ba1de541ea70db566bcfb1bd50d1d diff --git a/docs/cookbook/adding-a-settings-card.md b/docs/cookbook/adding-a-settings-card.md index cc8daa90bc..72d7eaeb85 100644 --- a/docs/cookbook/adding-a-settings-card.md +++ b/docs/cookbook/adding-a-settings-card.md @@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void { A plugin with something to say about a bundle, a row, or an official plugin it does not own registers into `plugins.detail.actions` (a control at the head of the page), `plugins.detail.badge` (a tag beside the title), or `plugins.detail.section` (a section under the page's own content). Every entry is rendered with the page's `subject` — `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }` — and returns null for a subject it has nothing for: -```ts ignore-check +```tsx ignore-check ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ name: 'plugins.detail.badge', id: 'acme-update', @@ -96,6 +96,8 @@ ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export — but it attaches a package's half to the Loader row whose specifier is the bare package name. A row mounted from a subpath export never carries a half, so a bundle that splits one package into several rows keeps its half on the root row, and every page it registers goes away when that row is switched off. A sub-plugin whose page must outlive the other rows ships as its own package. +The built `./client` file must be in the client module system's lazy-CJS factory format: one script that registers the package name and a `factory(require)` with the page's module loader, described in the [client module system's README](../../packages/client/modules/README.md). The `clientBundle` tsdown preset that emits it lives in `packages/client/tsdown.client.ts` rather than in a published package, so a package outside this repository reproduces that build itself. + ```jsonc { "exports": { diff --git a/docs/cookbook/adding-a-settings-card.zh.md b/docs/cookbook/adding-a-settings-card.zh.md index a0fce4075b..d125407808 100644 --- a/docs/cookbook/adding-a-settings-card.zh.md +++ b/docs/cookbook/adding-a-settings-card.zh.md @@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void { 对某个不属于自己的组合包、行或官方插件有话要说的插件,注册进 `plugins.detail.actions`(页头的控件)、`plugins.detail.badge`(标题旁的标签)或 `plugins.detail.section`(页面自身内容之下的区块)。每个条目都以页面的 `subject` 渲染——`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`——对无话可说的 subject 返回 null: -```ts ignore-check +```tsx ignore-check ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ name: 'plugins.detail.badge', id: 'acme-update', @@ -96,6 +96,8 @@ ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ 浏览器半侧由[客户端模块系统](../../packages/client/modules)送到页面:它扫描已启用的 Loader 条目,找出声明了 `dsh.client` 的包,送出每个包构建好的 `./client` 导出——但它只把一个包的半侧挂在说明符恰为裸包名的那一行上。从子路径导出挂载的行永远不带半侧,因此把一个包拆成多行的组合包,其半侧留在根行上,它注册的每个页面都随根行关闭而消失。需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 +构建出的 `./client` 文件必须是客户端模块系统的 lazy-CJS factory 格式:一段脚本,向页面的模块加载器登记包名和一个 `factory(require)`,见[客户端模块系统的 README](../../packages/client/modules/README.zh.md)。生成它的 `clientBundle` tsdown 预设位于 `packages/client/tsdown.client.ts`,而不在任何已发布的包里,因此仓库之外的包要自己复刻这一步构建。 + ```jsonc { "exports": { diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 8ade59ad22..8966c3d3e0 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -359,8 +359,9 @@ - id: ui-agent-preset name: '@deepseek-ai/dsh-client-ui-agent-preset' - # Plugin configuration: the host-plane sections a user owns, as expandable - # cards. A namespace this deployment does not expose renders nothing. + # The Built-in plugins settings section: the navigation entry and the tab + # row the inventory tab registers into. The official configuration pages + # are the companion rows below. - id: ui-settings-plugins name: '@deepseek-ai/dsh-client-ui-settings-plugins' diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 2a01a6de69..89f30f6340 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 3a1a54f801090456a1038d0e3d59fe5223374f3c -README.zh.md: 231449ab574224a3e4fc5436c57e9ece5b0183b5 +README.md: 7b6c88ebbbfdf97f1ec191a2e8133c488ecb422d +README.zh.md: 11846c189afd85817d33e8266bb105b5f5418314 diff --git a/packages/client/README.md b/packages/client/README.md index 3a1a54f801..7b6c88ebbb 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -67,12 +67,16 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces | — | | [`ui-permission-presets/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access | — | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control | — | -| [`ui-settings-plugins/`](ui-settings-plugins/README.md) | Owns the Plugins settings section, its tab extension point, and configurable host-plane plugin cards | — | +| [`ui-settings-plugins/`](ui-settings-plugins/README.md) | Owns the Built-in plugins settings section shell and its tab extension point | — | | [`ui-user-questions/`](ui-user-questions/README.md) | Presents interactive questions requested by the agent | — | | [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions | — | | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas | — | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — | | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | +| [`ui-settings-shell/`](ui-settings-shell/README.md) | Provides the shell settings page on the Plugins page | — | +| [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.md) | Provides the agent-loop settings page on the Plugins page | — | +| [`ui-settings-subagent/`](ui-settings-subagent/README.md) | Provides the Subagent settings page on the Plugins page | — | +| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web-search settings page on the Plugins page | — | | [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card with its comparison tabs, delivery cards, and clickable final-response file references | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 231449ab57..11846c189a 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -67,12 +67,16 @@ kind: "package-group" | [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择 | — | | [`ui-permission-presets/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式 | — | | [`ui-plan/`](ui-plan/README.zh.md) | 展示生效中的 plan mode 状态及其退出控件 | — | -| [`ui-settings-plugins/`](ui-settings-plugins/README.zh.md) | 负责「插件」设置分区、其标签页扩展点与可配置的宿主平面插件卡片 | — | +| [`ui-settings-plugins/`](ui-settings-plugins/README.zh.md) | 负责「内置插件」设置分区壳及其标签页扩展点 | — | | [`ui-user-questions/`](ui-user-questions/README.zh.md) | 展示 agent 请求的交互式问题 | — | | [`ui-agent-preset/`](ui-agent-preset/README.zh.md) | 选择会话的 agent 预设并编写预设组合 | — | | [`ui-settings/`](ui-settings/README.zh.md) | 承载设置界面及其扩展区域 | — | | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — | | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | +| [`ui-settings-shell/`](ui-settings-shell/README.zh.md) | 在插件页提供终端设置页 | — | +| [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.zh.md) | 在插件页提供 Agent 循环设置页 | — | +| [`ui-settings-subagent/`](ui-settings-subagent/README.zh.md) | 在插件页提供 Subagent 设置页 | — | +| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索设置页 | — | | [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片及其对比 tab、交付文件卡片与可点击的最终响应文件引用 | — | diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 795636bb9f..7c58898be2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: 4b6c6752f8dc71f7c00324cf56d236631810f752 -README.zh.md: f7a9ebe9efba83e6b9f5fb46b6422c2e099ead04 +README.md: a31960cab79b693f3d0f3b01a26d484b96ec404f +README.zh.md: 86f06fbeaaf0935f4d4cbdc639e95e6c75173dd6 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 4b6c6752f8..a31960cab7 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -45,7 +45,7 @@ A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, whic ### Configuration pages -A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page asks every entry for two views through its owner props: `view: 'summary'` for the one-liner under the title, `view: 'page'` for the form with its own save control. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, subagent model selection, and the DeepSeek search provider — come from [ui-settings-plugins](../ui-settings-plugins/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way: +A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page asks every entry for two views through its owner props: `view: 'summary'` for the one-liner under the title, `view: 'page'` for the form with its own save control. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, Subagent, and the DeepSeek search provider — come from one companion package each, [ui-settings-shell](../ui-settings-shell/README.md), [ui-settings-agent-loop](../ui-settings-agent-loop/README.md), [ui-settings-subagent](../ui-settings-subagent/README.md), and [ui-settings-web-search](../ui-settings-web-search/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way: ```tsx ignore-check ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ @@ -105,7 +105,7 @@ These pages cover the sidebar, the Remote calls, and the Host-side manager. - [ui-sidebar](../ui-sidebar/README.md) — the panel list the Plugins entry registers into; [ui-layout](../ui-layout/README.md) — the main slot the page occupies. - [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `pluginManager.*` and `pluginInventory.*`. - [plugin-manager](../../boot/plugin-manager/README.md) — the Host-side manager this page drives. -- [ui-settings-plugins](../ui-settings-plugins/README.md) — the official configuration pages that register into this page's slots. +- [ui-settings-shell](../ui-settings-shell/README.md), [ui-settings-agent-loop](../ui-settings-agent-loop/README.md), [ui-settings-subagent](../ui-settings-subagent/README.md), [ui-settings-web-search](../ui-settings-web-search/README.md) — the official configuration pages that register into this page's `plugins.item` slot. ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index f7a9ebe9ef..86f06fbeaa 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -45,7 +45,7 @@ Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 三个包使用 ### 配置页 -自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面通过 owner props 向每个条目索取两种视图:`view: 'summary'` 是标题下的一句话简介,`view: 'page'` 是带自己保存控件的表单。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、subagent 模型选择、DeepSeek 搜索提供方——来自 [ui-settings-plugins](../ui-settings-plugins/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册: +自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面通过 owner props 向每个条目索取两种视图:`view: 'summary'` 是标题下的一句话简介,`view: 'page'` 是带自己保存控件的表单。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、Subagent、DeepSeek 搜索提供方——各来自一个伴生包:[ui-settings-shell](../ui-settings-shell/README.zh.md)、[ui-settings-agent-loop](../ui-settings-agent-loop/README.zh.md)、[ui-settings-subagent](../ui-settings-subagent/README.zh.md) 与 [ui-settings-web-search](../ui-settings-web-search/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册: ```tsx ignore-check ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ @@ -105,7 +105,7 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ - [ui-sidebar](../ui-sidebar/README.zh.md)——插件入口注册进的面板列表;[ui-layout](../ui-layout/README.zh.md)——页面占用的主 slot。 - [api-remotes](../../api/remotes/README.zh.md)——`pluginManager.*` 与 `pluginInventory.*` 背后的 Remote BFF 面。 - [plugin-manager](../../boot/plugin-manager/README.zh.md)——本页驱动的 Host 侧管理器。 -- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——注册进本页 slot 的官方配置页。 +- [ui-settings-shell](../ui-settings-shell/README.zh.md)、[ui-settings-agent-loop](../ui-settings-agent-loop/README.zh.md)、[ui-settings-subagent](../ui-settings-subagent/README.zh.md)、[ui-settings-web-search](../ui-settings-web-search/README.zh.md)——注册进本页 `plugins.item` slot 的官方配置页。 ----- diff --git a/packages/client/ui-primitives/tests/settings-form-model.client.spec.ts b/packages/client/ui-primitives/tests/settings-form-model.client.spec.ts index 66fb0b5ad8..a2a0ef86ca 100644 --- a/packages/client/ui-primitives/tests/settings-form-model.client.spec.ts +++ b/packages/client/ui-primitives/tests/settings-form-model.client.spec.ts @@ -270,7 +270,7 @@ describe('SettingsFormModel', () => { expect(store.getSnapshot()).toBe('2000') }) - it('refuses to address a field the card never declared', () => { + it('refuses to address a field the form never declared', () => { const { subject } = form() expect(() => subject.field('nope')).toThrow('settings form has no field nope') diff --git a/packages/client/ui-settings-subagent/package.json b/packages/client/ui-settings-subagent/package.json index 49ccf5295f..1f5858183d 100644 --- a/packages/client/ui-settings-subagent/package.json +++ b/packages/client/ui-settings-subagent/package.json @@ -30,7 +30,8 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-plugin-manager" + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } diff --git a/packages/client/ui-settings-web-search/package.json b/packages/client/ui-settings-web-search/package.json index 4959b285bc..becf2a405a 100644 --- a/packages/client/ui-settings-web-search/package.json +++ b/packages/client/ui-settings-web-search/package.json @@ -30,7 +30,8 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-plugin-manager" + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index f325c7e238..861b4012de 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: 760b4a4567481b561ce319926149815119018177 -README.zh.md: f8e2128d734b650b7cd7a10c180f90ea01b77ab5 +README.md: d19de7bea23b1d752b977aea06d868e029fc3331 +README.zh.md: 5e30e94f7436dc93c3d6f06a58ed771aa314bff7 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 760b4a4567..d19de7bea2 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -31,6 +31,10 @@ Feature plugins use this package to store and edit their preferences without re- A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. +### Following served namespaces + +A page that edits a namespace another plugin owns registers through `ctx.settingsScope.whileServed(namespaces, register)`: `register` runs once any listed namespace is in the shared mirror and returns the registration's disposer, which runs when none of them is served any more or when the disposer `whileServed` returns runs. The caller owns that returned disposer and wraps it in `ctx.effect`; unlike `bind`, the service registers nothing on the caller's context. A deployment that never composed the owner therefore shows no trace of the page, and a namespace the Host stops serving withdraws it. The four official pages on the Plugins page ride it, one companion package each. + ### Filling the settings slots A settings surface registers into the slot types this package declares. The shell (`sidebar.settings` occupant, navigation, chrome) lives in ui-settings-general; feature pages register `settings.section` contributions; the Plugins section hosts `settings.plugins.tab` pages; onboarding steps register `settings.onboarding`. Cross-namespace surfaces (schema introspection, the served-namespace directory, `hasDocument`) read the same mirror through `ctx.settingsScope.describe()`. @@ -55,7 +59,7 @@ The plugin injects `remote` with its `settings` namespace, resolves Host persist ### Scope derivation -`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. +`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. `whileServed` subscribes to the same mirror and re-evaluates its served set on every snapshot, so a registration toggles with the describe view and needs no wire read beyond the mirror's `ensure`. ### Schema service @@ -71,7 +75,8 @@ The plugin injects `remote` with its `settings` namespace, resolves Host persist These pages cover the settings surface family and the durable seam behind it. - [ui-settings-general](../ui-settings-general/README.md) — the settings shell: trigger chrome, navigation, General section, onboarding projection. -- [ui-settings-plugins](../ui-settings-plugins/README.md) — the Plugins section and its configurable host-plane cards. +- [ui-settings-plugins](../ui-settings-plugins/README.md) — the Built-in plugins section shell around the inventory tab. +- [ui-settings-shell](../ui-settings-shell/README.md), [ui-settings-agent-loop](../ui-settings-agent-loop/README.md), [ui-settings-subagent](../ui-settings-subagent/README.md), [ui-settings-web-search](../ui-settings-web-search/README.md) — the official configuration pages on the Plugins page, each following its namespaces through `whileServed`. - [ui-settings-models](../ui-settings-models/README.md) — the Models page and DeepSeek onboarding over this base. - [settings](../../settings/README.md) — the durable user-settings seam and its file provider. - [ui-sidebar](../ui-sidebar/README.md) — the sidebar shell whose bottom seat hosts the settings trigger. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index f8e2128d73..5e30e94f74 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -31,6 +31,10 @@ kind: "package-reference" 功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 +### 跟随被服务的命名空间 + +编辑另一个插件所拥有命名空间的页面通过 `ctx.settingsScope.whileServed(namespaces, register)` 注册:只要所列命名空间中的任一个进入共享镜像,`register` 就运行一次并返回该注册的 disposer;当它们全都不再被服务,或 `whileServed` 返回的 disposer 被调用时,这个 disposer 就运行。调用方持有返回的 disposer 并把它包进 `ctx.effect`;与 `bind` 不同,服务不会在调用方的 context 上注册任何东西。因此从未组合过所有者的部署看不到该页面的任何痕迹,Host 停止服务的命名空间会撤下它。插件页上的四个官方页面各自通过一个伴生包走这条路径。 + ### 填充设置 slot 设置界面会注册进本包声明的 slot 类型。外壳(`sidebar.settings` 占位方、导航、界面框架)位于 ui-settings-general;功能页面注册 `settings.section` 贡献;「插件」分区承载 `settings.plugins.tab` 页面;首次使用引导步骤注册 `settings.onboarding`。跨命名空间的表面(schema 内省、已服务命名空间目录、`hasDocument`)通过 `ctx.settingsScope.describe()` 读同一面镜像。 @@ -55,7 +59,7 @@ kind: "package-reference" ### Scope 派生 -`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 +`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。`whileServed` 订阅同一面镜像,在每次快照变化时重新判断被服务集合,因此注册随 describe 视图开关,除镜像的 `ensure` 外不需要任何线路读取。 ### Schema 服务 @@ -71,7 +75,8 @@ kind: "package-reference" 以下页面覆盖设置界面家族及其背后的持久化 seam。 - [ui-settings-general](../ui-settings-general/README.zh.md)——设置外壳:触发控件、导航、「通用」分区、引导投影。 -- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——「插件」分区及其可配置宿主平面卡片。 +- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——围绕清单标签页的「内置插件」分区壳。 +- [ui-settings-shell](../ui-settings-shell/README.zh.md)、[ui-settings-agent-loop](../ui-settings-agent-loop/README.zh.md)、[ui-settings-subagent](../ui-settings-subagent/README.zh.md)、[ui-settings-web-search](../ui-settings-web-search/README.zh.md)——插件页上的官方配置页,各自通过 `whileServed` 跟随其命名空间。 - [ui-settings-models](../ui-settings-models/README.zh.md)——建立在本底座之上的 Models 页面与 DeepSeek 引导。 - [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。 - [ui-sidebar](../ui-sidebar/README.zh.md)——底部席位承载设置触发控件的侧边栏外壳。 diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 2dec36af4e..f9fa3af55d 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -303,7 +303,9 @@ export class SettingsScopeBinder extends Service { * disposer runs when none is or when the returned disposer runs. A plugin * whose page edits a namespace another plugin owns registers the page * through this, so a deployment that never composed the owner shows no - * trace of the page. + * trace of the page. The caller owns the returned disposer and wraps it in + * `ctx.effect`; unlike {@link bind}, nothing is registered on the caller's + * context here. * @param namespaces - the settings namespaces the registration follows. * @param register - registers the contribution; returns its disposer. * @returns the disposer ending the watch and any live registration. diff --git a/website/docs.ts b/website/docs.ts index e366926c9c..5279241051 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -440,7 +440,7 @@ const reference = [ ['adding-a-package.md', '新增 Package', 'Adding a package'], ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], ['adding-an-llm-adapter.md', '新增 LLM Adapter', 'Adding an LLM adapter'], - ['adding-a-settings-card.md', '新增设置卡片', 'Adding a settings card'], + ['adding-a-settings-card.md', '新增设置页', 'Adding a settings page'], ['extension-cookbook.md', '扩展模式', 'Extension patterns'], ] as const).map(([file, rootLabel, enLabel], order): PairedPage => ({ source: `docs/cookbook/${file}`, From 9f3ddc1a19cae3be60371923aba86c9cc687b99f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 19 Sep 2026 10:41:31 +0800 Subject: [PATCH 004/121] test(web): re-record the bundle golden after the detail-page chrome change The crumb, uninstall, and configure controls no longer render an icon child in the accessibility tree; the bundle page golden this branch added follows the row and official goldens master re-recorded. --- .../tests/expected/plugin-config/bundle.expected.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md index 23985c8c66..50cb737738 100644 --- a/apps/web/tests/expected/plugin-config/bundle.expected.md +++ b/apps/web/tests/expected/plugin-config/bundle.expected.md @@ -1,10 +1,6 @@ -- button "返回插件列表": - - img - - text: 插件列表 +- button "返回插件列表": 插件列表 - button "夹具操作" -- button "卸载 live-client": - - img - - text: 卸载 +- button "卸载 live-client": 卸载 - switch "启用 live-client" [checked] - heading "live-client" [level=3] - text: v0.0.1 夹具标签 @@ -15,9 +11,7 @@ - text: 共 1 个 · 1 运行中 - list: - listitem: - - button "配置 fixture-live-client": - - text: fixture-live-client - - img + - button "配置 fixture-live-client": fixture-live-client - text: "@fixture/live-client 运行中" - switch "启用组件 fixture-live-client" [checked] - region "夹具区块": From 3bcac0b87872d2380b74fde9cd85f9dfa5d6858e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 19 Sep 2026 10:46:51 +0800 Subject: [PATCH 005/121] test(web): expect three startup plugin batches in the keyless smoke The application phase's map-form combo URL passes the 3 KiB limit once the four settings companions join the composition, so the module system partitions that phase into two combos beside the bootstrap one, as the loading model allows. The smoke pins the three immutable batches and still checks every combo's script, map, and cache header. --- apps/web/tests/smoke-real.e2e.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 7d78803cc7..77acfed00a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -309,7 +309,7 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => { if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`) describe('dsh web keyless CLI smoke', () => { - it('serves a usable app from two immutable plugin batches', async () => { + it('serves a usable app from three immutable plugin batches', async () => { requireDist() const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-')) const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href @@ -356,10 +356,13 @@ describe('dsh web keyless CLI smoke', () => { await page.goto(readyUrl) await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 }) const batchPaths = [...new Set(pluginScripts)].sort() - expect(batchPaths).toHaveLength(2) - expect(batchPaths).toContainEqual(expect.stringMatching( - /^\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/, - )) + // The bootstrap phase is the modules package alone; the application phase + // spans two combos because its map-form URL is over the 3 KiB combo limit + // since the four settings companions joined the composition. + expect(batchPaths).toHaveLength(3) + expect(batchPaths.filter(path => ( + /^\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/.test(path) + ))).toHaveLength(2) expect(batchPaths).toContainEqual(expect.stringMatching( /^\/plugins\/\?\?@deepseek-ai\/dsh-client-modules\/client\.js&rev=[a-f\d]{12}$/, )) @@ -367,6 +370,7 @@ describe('dsh web keyless CLI smoke', () => { expect([...cacheHeaders.values()]).toEqual([ 'public, max-age=31536000, immutable', 'public, max-age=31536000, immutable', + 'public, max-age=31536000, immutable', ]) for (const path of batchPaths) { const [scriptResponse, mapResponse] = await Promise.all([ From 753eff67b137724831e13e4ac0be152dfb9cf998 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:30:20 +0800 Subject: [PATCH 006/121] feat(desktop,client): add the native welcome window --- ...-desktop-welcome-window-material.i18n.yaml | 6 + ...6-09-08-desktop-welcome-window-material.md | 37 +++ ...9-08-desktop-welcome-window-material.zh.md | 37 +++ apps/desktop/README.md | 35 ++- apps/desktop/README.zh.md | 35 ++- .../desktop/renderer/assets/welcome-brand.svg | 44 +++ apps/desktop/renderer/welcome.css | 284 ++++++++++++++++++ apps/desktop/renderer/welcome.html | 38 +++ apps/desktop/renderer/welcome.js | 113 +++++++ apps/desktop/scripts/dev.ts | 5 +- apps/desktop/scripts/development-project.ts | 32 +- apps/desktop/src/ipc.ts | 2 + apps/desktop/src/locale.ts | 72 +++++ apps/desktop/src/main.ts | 202 ++++++++++++- apps/desktop/src/preload-app.ts | 13 + apps/desktop/src/preload-welcome.ts | 15 + apps/desktop/src/welcome-api.ts | 45 +++ apps/desktop/src/welcome-backend.ts | 117 ++++++++ apps/desktop/src/welcome-window.ts | 86 ++++++ .../desktop/tests/development-project.spec.ts | 23 +- .../expected/welcome/en-api-key.expected.txt | 8 + .../tests/expected/welcome/en.expected.txt | 5 + .../welcome/zh-CN-api-key.expected.txt | 8 + .../tests/expected/welcome/zh-CN.expected.txt | 5 + apps/desktop/tests/locale.spec.ts | 12 +- apps/desktop/tests/main-startup.spec.ts | 65 +++- apps/desktop/tests/preload.e2e.ts | 37 +++ apps/desktop/tests/welcome-flow.e2e.ts | 92 ++++++ apps/desktop/tests/welcome-host.spec.ts | 76 +++++ apps/desktop/tests/welcome-renderer.spec.ts | 150 +++++++++ apps/desktop/tests/welcome-startup.spec.ts | 134 +++++++++ apps/desktop/tests/welcome-window.spec.ts | 130 ++++++++ apps/desktop/tsdown.config.ts | 5 +- apps/web/tests/desktop-locale.e2e.ts | 83 +++++ .../desktop-locale/automatic.expected.md | 50 +++ .../desktop-locale/selected.expected.md | 50 +++ .../onboarding-native/models.expected.md | 35 +++ .../onboarding-native/cordis.patch.yml | 3 + apps/web/tests/onboarding-native.e2e.ts | 70 +++++ apps/web/tsconfig.json | 2 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 15 +- docs/config-catalog.zh.md | 15 +- packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 + packages/client/locale/README.zh.md | 2 + .../client/locale/src/client/bootstrap.ts | 31 ++ packages/client/locale/src/client/index.ts | 51 +++- .../client/locale/tests/apply.client.spec.ts | 52 +++- .../locale/tests/bootstrap.client.spec.ts | 75 +++++ .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../client/ui-settings-models/package.json | 8 +- .../ui-settings-models/src/client/index.ts | 20 +- .../client/ui-settings-models/src/index.ts | 24 +- .../src/onboarding-config.ts | 17 ++ .../tests/apply.client.spec.ts | 45 ++- .../client/ui-settings-models/tsconfig.json | 2 + .../ui-trajectory/tests/views.client.spec.tsx | 4 +- pnpm-lock.yaml | 7 + tsconfig.host.json | 3 + vitest.e2e.config.ts | 4 +- 63 files changed, 2588 insertions(+), 66 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md create mode 100644 apps/desktop/renderer/assets/welcome-brand.svg create mode 100644 apps/desktop/renderer/welcome.css create mode 100644 apps/desktop/renderer/welcome.html create mode 100644 apps/desktop/renderer/welcome.js create mode 100644 apps/desktop/src/preload-welcome.ts create mode 100644 apps/desktop/src/welcome-api.ts create mode 100644 apps/desktop/src/welcome-backend.ts create mode 100644 apps/desktop/src/welcome-window.ts create mode 100644 apps/desktop/tests/expected/welcome/en-api-key.expected.txt create mode 100644 apps/desktop/tests/expected/welcome/en.expected.txt create mode 100644 apps/desktop/tests/expected/welcome/zh-CN-api-key.expected.txt create mode 100644 apps/desktop/tests/expected/welcome/zh-CN.expected.txt create mode 100644 apps/desktop/tests/preload.e2e.ts create mode 100644 apps/desktop/tests/welcome-flow.e2e.ts create mode 100644 apps/desktop/tests/welcome-host.spec.ts create mode 100644 apps/desktop/tests/welcome-renderer.spec.ts create mode 100644 apps/desktop/tests/welcome-startup.spec.ts create mode 100644 apps/desktop/tests/welcome-window.spec.ts create mode 100644 apps/web/tests/desktop-locale.e2e.ts create mode 100644 apps/web/tests/expected/desktop-locale/automatic.expected.md create mode 100644 apps/web/tests/expected/desktop-locale/selected.expected.md create mode 100644 apps/web/tests/expected/onboarding-native/models.expected.md create mode 100644 apps/web/tests/fixtures/onboarding-native/cordis.patch.yml create mode 100644 apps/web/tests/onboarding-native.e2e.ts create mode 100644 packages/client/locale/src/client/bootstrap.ts create mode 100644 packages/client/locale/tests/bootstrap.client.spec.ts create mode 100644 packages/client/ui-settings-models/src/onboarding-config.ts diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml new file mode 100644 index 0000000000..42ff4b7b0f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md +2026-09-08-desktop-welcome-window-material.md: b0cb8a0bdd8abcf472597d5bfceaa175195718de +2026-09-08-desktop-welcome-window-material.zh.md: f917ad503c4348d9a44a862ba6d005c1e5ff0198 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md new file mode 100644 index 0000000000..b0cb8a0bdd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md @@ -0,0 +1,37 @@ +# Agent Note: Desktop welcome window material + +Status: implemented + +English | [中文](2026-09-08-desktop-welcome-window-material.zh.md) + +## Problem + +The desktop welcome design blurs the desktop behind its entire window. Chromium backdrop filters only sample content inside the renderer. Credential setup also needs an explicit startup owner so a native skip is not followed by a second Web dialog. + +## Decision + +The [Electron welcome window](../../../../apps/desktop/src/welcome-window.ts) owns native material and window controls. Its local renderer supplies the entry and API-key form. A narrow preload supplies typed shell copy, a write-only key operation, and a skip operation; IPC rejects other windows and subframes. Each sandboxed preload is bundled independently because Electron’s restricted require cannot load sibling chunks. The Electron main process authenticates through the shared Web launch URL and resolves the official provider's reference through the existing settings and credential RPC methods. The [thin Web wrapper](2026-09-10-desktop-web-wrapper.md) owns the HTTP server; onboarding adds no Host endpoint or child IPC operation. Responses contain metadata or a safe outcome, never a key or private provider diagnostics. + +Cold startup opens the entry when no model API key is configured. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a completion flag. The next process launch checks the credential store again. The Desktop preload marker suppresses only the Web credential step; settings and the welcome notice remain mounted. Other native shells can set `credentialOnboarding` through the Models Host plugin, which publishes that boolean through the page-injection event before dialogs register. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. The authentication decision accepts independent account and API-key facts, but account login and logout events have no connected provider yet, so startup supplies the current unsigned-in state. + +The development launcher accepts `--preview-welcome` to force the entry while using the complete Desktop startup and operations. It shares the normal development home, profile, browser data, and single-instance lock. The flag affects only the initial page decision; saving and skipping use the production callbacks. Packaged startup does not activate that switch. The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins. + +Desktop reads the shared `locale.preference` before opening the welcome window. The Client awaits an isolated preload read of that preference and the same OS language order before mounting, then reports resolved locale changes to the shell. Only an explicit Settings selection writes the preference; automatic detection stays provisional. The shell ships English and Simplified Chinese dictionaries, so a Client-only language pack falls back to a supported OS language in native windows. + +The welcome flow shares the Desktop backend controller with startup recovery. The main recovery document loads offscreen while the Host starts; a failure reveals it, and successful startup chooses the welcome window or workspace after reading credentials and locale. Recovery actions retain the controller’s serialized retries and awaited child shutdown. The application preload exposes recovery controls only to shell documents and the locale bridge only to application documents. + +## Alternatives considered + +**A CSS-only backdrop filter.** It cannot blur other application windows or the desktop, so it cannot provide the required native effect. + +**Persisting a completed-onboarding flag.** A skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials. + +**Keeping both native and Web credential dialogs.** Their separate completion state would ask for a key twice after a native skip. The composition assigns credential onboarding to one owner. + +**Drawing replacement traffic lights.** Native controls preserve platform window behavior and accessibility. Their dimensions and outer window corners follow the OS rather than reproducing Figma geometry exactly. + +**Separate preview callbacks.** A visual-only skip cannot demonstrate entry into the workspace and can hide broken startup dependencies. The preview uses the same callbacks and Host as the product. + +## Consequences + +Native blur strength and font fallback vary by system. macOS uses titlebar vibrancy behind a transparent renderer. The design specifies an 80% white fill and a 50px backdrop blur; native materials already supply their own tint, so those values do not map directly to an additional CSS overlay. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and a built-Host acceptance test proves credential persistence across restarts. Account sign-out integration remains dependent on an account provider; its decision must preserve independent API keys. Onboarding presentation produces no Session events. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md new file mode 100644 index 0000000000..f917ad503c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 桌面欢迎窗口材质 + +Status: implemented + +[English](2026-09-08-desktop-welcome-window-material.md) | 中文 + +## 问题 + +桌面欢迎界面的设计要求整个窗口模糊其背后的桌面。Chromium 背景滤镜只能采样渲染器内部的内容。凭证配置还需要明确的启动归属,避免跳过原生引导后又出现 Web 弹窗。 + +## 决策 + +[Electron 欢迎窗口](../../../../apps/desktop/src/welcome-window.ts)负责原生材质和窗口控件。本地渲染器提供入口和 API Key 表单。窄接口 preload 提供强类型桌面文案、仅写密钥操作和跳过操作;IPC 拒绝其他窗口和子 frame。每个沙箱 preload 都独立打包,因为 Electron 受限的 require 无法加载同目录中的拆分模块。Electron 主进程通过共享 Web 启动 URL 认证,并通过现有设置和凭证 RPC 方法解析官方提供方的引用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)负责 HTTP 服务;引导不增加 Host 端点或子进程 IPC 操作。响应只包含元数据或安全结果,不包含密钥或提供方的私有诊断。 + +冷启动在没有已配置的模型 API Key 时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或完成标记。下次进程启动时会重新检查凭证存储。Desktop preload 标记只抑制 Web 凭证步骤;设置和欢迎须知仍然挂载。其他原生壳可以通过 Models Host 插件设置 `credentialOnboarding`,由页面注入事件在注册弹窗前发布该布尔值。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。认证判断接受独立的账号与 API Key 状态,但账号登录和退登事件尚无已接入的提供方,因此启动传入当前未登录状态。 + +开发启动器接受 `--preview-welcome`,在使用完整 Desktop 启动流程和操作的同时强制显示入口。它共用普通开发环境的 home、profile、浏览器数据和单实例锁。该标志只影响初始页面判断;保存和跳过使用产品回调。打包后的启动不会激活该开关。生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。 + +Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在挂载前等待隔离 preload 返回该偏好和同一系统语言顺序,之后将解析后的语言变更通知桌面壳。只有设置中的明确选择会写入偏好;自动检测保持临时状态。桌面壳自带英文和简体中文词典,因此仅在 Client 中提供的语言包会在原生窗口回退到受支持的系统语言。 + +欢迎流程与启动恢复共用 Desktop 后端控制器。Host 启动期间,主窗口在屏幕外加载恢复文档;失败时显示该文档,成功后读取凭证和语言,再选择欢迎窗口或工作区。恢复操作保留控制器的串行重试与等待子进程退出的行为。应用 preload 只向壳文档提供恢复控件,只向应用文档提供语言桥接。 + +## 考虑过的替代方案 + +**仅使用 CSS 背景滤镜。** 它无法模糊其他应用窗口或桌面,因此不能提供所需的原生效果。 + +**持久化已完成引导标记。** 跳过只对当前进程有效;保存该标记会在用户仍无凭证时抑制下次冷启动的选择。 + +**同时保留原生与 Web 凭证弹窗。** 两者独立的完成状态会在跳过原生引导后再次要求输入密钥。组合将凭证引导交给单一归属方。 + +**绘制替代红绿灯。** 原生控件保留平台窗口行为和无障碍能力。它们的尺寸与窗口外部圆角遵循操作系统,不严格复制 Figma 几何参数。 + +**独立的预览回调。** 纯视觉预览中的跳过无法证明能进入工作区,还会掩盖启动依赖缺失。预览使用与产品相同的回调和 Host。 + +## 后果 + +原生模糊强度和字体回退因系统而异。macOS 在透明渲染器后使用 titlebar vibrancy。设计指定了不透明度 80% 的白色填充和 50px 背景模糊;原生材质自身已有底色,因此这些数值不能直接映射为额外的 CSS 遮罩。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收测试证明凭证可跨重启持久化。账号退登的接入仍依赖账号提供方;其判断必须保留独立的 API Key。引导展示不会生成 Session 事件。 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 531c168c0d..29d8a04b6f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,6 +10,8 @@ Desktop’s local native directory flow opens an Electron folder dialog attached Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. +Embedded Platform documents use a dedicated non-persistent WebContentsView session. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode and a synchronous getAuthToken() that reads preload memory without further IPC. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. @@ -60,7 +62,7 @@ On macOS the custom application menu also declares the standard File, Window, an The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch. -1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. +1. The main window loads the shared Web loading page offscreen from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. 2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm. 3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm. 4. The main application’s Plugins page uses the shared [plugin manager](../../packages/boot/plugin-manager/README.md) against the Desktop profile. Package operations use bundled pnpm with normal user and profile configuration. @@ -77,6 +79,8 @@ Recovery waits for Host shutdown before changing plugin activation. The native r ## Develop +The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files. + `dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI and private Desktop Host packages with their workspace dependencies into a disposable desktop npm project, and launches Electron without resolving dsh from npm: ```sh @@ -93,6 +97,23 @@ pnpm run start:desktop Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. + +### Startup onboarding + +Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the [API-key page](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626). Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice. + +The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector. + +### Welcome window preview + +Build the complete Desktop application and force its welcome page, including when a key is already configured: + +```sh +pnpm run dev:desktop --preview-welcome +``` + +The preview uses the same Host, development project, Harness home, browser data, and single-instance lock as normal development startup. It follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and an Old Style serif stack for the tagline. The window uses macOS titlebar vibrancy or Windows acrylic behind a transparent renderer, without an additional white overlay or whole-window opacity reduction. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The flag changes only the initial welcome-page decision. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions. + ## Package @@ -321,10 +342,22 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i ## Known limitations +- Account sign-in is not connected; the Sign in button is disabled. Windows material rendering still requires platform QA. + - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment. - Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog. - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate. +Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. + +An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry. + +Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. + +Private Platform deployment headers are injected by the embedded browser session only for its configured origin, including document and API requests. Cookie overrides merge by name. Cross-origin requests discard deployment headers; bootstrap exposes only origin and token. + +The account provider’s `embeddedPageDist` configuration adds a `dist` query parameter to embedded Usage and Top-up URLs. Its default is empty; private frontend branch selectors belong in the local profile patch. It does not change API URLs or credential delivery. + ## Dev Note Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 6a20f83623..cf6cc0fa88 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,6 +10,8 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件 Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 +内嵌 Platform 文档使用独立且不持久化的 WebContentsView 会话。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode,以及同步读取 preload 内存且不再调用 IPC 的 getAuthToken()。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 @@ -60,7 +62,7 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 -1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。 +1. 主窗口在 profile 准备或后端启动前,从打包静态资源于屏幕外加载共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。 2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。 3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。 4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。 @@ -78,6 +80,8 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 ## 开发 +开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。 + `dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包、私有 Desktop Host 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不从 npm 解析 dsh: ```sh @@ -94,6 +98,23 @@ pnpm run start:desktop Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 + +### 启动引导 + +Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 [API Key 页面](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626)。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。 + +欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。 + +### 欢迎窗口预览 + +构建完整 Desktop 应用并强制显示欢迎页,即使已有配置好的密钥: + +```sh +pnpm run dev:desktop --preview-welcome +``` + +预览与普通开发启动共用 Host、开发项目、Harness home、浏览器数据和单实例锁。它使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及标语使用的 Old Style 衬线字体栈。窗口在透明渲染器后使用 macOS titlebar vibrancy 或 Windows acrylic,不额外叠加白色遮罩,也不降低整个窗口的不透明度。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。该标志只改变首次是否展示欢迎页的判断。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。 + ## 打包 @@ -322,10 +343,22 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd ## 已知限制 +- 账号登录尚未接入;登录按钮禁用。Windows 材质效果仍需平台验证。 + - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。 - 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。 - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 +登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 + +登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 + +内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 + +私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin 和 token。 + +账号提供者的 `embeddedPageDist` 配置为内嵌用量和充值页面 URL 添加 `dist` 查询参数。默认值为空;私有前端分支选择值应写在本地 profile patch 中。此配置不改变 API 地址或凭证传递方式。 + ## 开发备注 上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。 diff --git a/apps/desktop/renderer/assets/welcome-brand.svg b/apps/desktop/renderer/assets/welcome-brand.svg new file mode 100644 index 0000000000..67c917d0bc --- /dev/null +++ b/apps/desktop/renderer/assets/welcome-brand.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css new file mode 100644 index 0000000000..2bfd791bb1 --- /dev/null +++ b/apps/desktop/renderer/welcome.css @@ -0,0 +1,284 @@ +/* Figma 2121:39336 / 2138:44626, Platform modes. Native material supplies the backdrop tint. */ +:root { + color-scheme: light dark; + --dsw-alias-label-primary: #0f1115; + --dsw-alias-label-secondary: #61666b; + --dsw-alias-label-tertiary: #81858c; + --dsw-alias-label-primary-inverted: #fff; + --dsw-alias-brand-primary: var(--dsw-alias-label-primary); + --dsw-alias-button-primary-hover: #43454a; + --dsw-alias-button-elevated-fill: #fff; + --dsw-alias-button-floating-hover: #f1f3f5; + --dsw-alias-border-l2: #0000001a; + --dsw-alias-border-l4: #00000029; + --dsw-alias-label-caption: #adb2b8; + --welcome-tint: transparent; + --ds-transition-duration: 0.2s; + --ds-transition-duration-slow: 0.3s; + --ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --dsw-font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + --dsw-font-family-serif: 'EB Garamond', 'Iowan Old Style', 'Palatino Linotype', + 'URW Palladio L', P052, serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --dsw-alias-label-primary: #f9fafb; + --dsw-alias-label-secondary: #cfd3d6; + --dsw-alias-label-tertiary: #adb2b8; + --dsw-alias-label-primary-inverted: #353638; + --dsw-alias-button-primary-hover: #ebeef2; + --dsw-alias-button-elevated-fill: #43454a; + --dsw-alias-button-floating-hover: #353638; + --dsw-alias-border-l2: #ffffff1f; + --dsw-alias-border-l4: #ffffff33; + --dsw-alias-label-caption: #81858c; + } +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: transparent; +} + +body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 6px 16px; + background: var(--welcome-tint); + color: var(--dsw-alias-label-primary); + font-family: var(--dsw-font-family); + user-select: none; + -webkit-font-smoothing: antialiased; +} + +.titlebar { + flex: 0 0 36px; + app-region: drag; +} + +.welcome { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + align-items: center; + justify-content: space-between; + padding: 24px 48px 76px; +} + +.welcome.key-page { + padding-bottom: 32px; +} + +.brand { + display: block; + flex: none; + width: 100%; + height: 40px; + user-select: none; + -webkit-user-drag: none; +} + +.tagline { + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 28px; + font-weight: 300; + line-height: 48px; + text-align: center; + white-space: nowrap; +} + +.tagline em { + color: var(--dsw-alias-label-primary); + font-family: var(--dsw-font-family-serif); + font-style: italic; + font-weight: 600; +} + +.actions { + display: flex; + flex: none; + flex-direction: column; + gap: 16px; + width: 320px; +} + +.actions button { + display: flex; + align-items: center; + justify-content: center; + height: 44px; + padding: 8px 16px; + border: 1px solid transparent; + border-radius: 10px; + font-family: inherit; + font-size: 16px; + font-weight: 500; + line-height: 22px; + opacity: 1; + app-region: no-drag; + cursor: pointer; + transition: + background-color var(--ds-transition-duration) var(--ds-ease-in-out), + border-color var(--ds-transition-duration) var(--ds-ease-in-out), + opacity var(--ds-transition-duration) var(--ds-ease-in-out), + color var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.actions button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.actions button:focus-visible { + outline: 2px solid var(--dsw-alias-label-primary); + outline-offset: 3px; +} + +.primary { + background: var(--dsw-alias-brand-primary); + color: var(--dsw-alias-label-primary-inverted); +} + +.primary:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); +} + +.actions .secondary { + border-color: var(--dsw-alias-border-l2); + background: var(--dsw-alias-button-elevated-fill); + color: var(--dsw-alias-label-primary); +} + +.actions .back { + align-self: center; + min-width: 58px; + height: 28px; + padding: 4px 10px; + border: 0; + border-radius: 24px; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; +} + +/* Platform text actions use color-only hover, preserving the native backdrop. */ +.actions .back:hover:not(:disabled) { + color: var(--dsw-alias-brand-primary); +} + +.actions .back:disabled { + color: var(--dsw-alias-label-caption); + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .actions button { + transition: none; + } +} + +.actions .secondary:hover:not(:disabled) { + background: var(--dsw-alias-button-floating-hover); +} + +.key-form { + display: flex; + flex-direction: column; + align-items: center; + gap: 40px; + width: 440px; +} + +.key-heading { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; +} + +.key-heading h1 { + margin: 0; + font-size: 24px; + font-weight: 500; + line-height: 32px; + letter-spacing: 0.01em; +} + +.key-heading p { + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 16px; + font-weight: 400; + line-height: 22px; + letter-spacing: 0.02em; +} + +.key-field { + position: relative; + width: 100%; +} + +/* Text inputs match :focus-visible after pointer focus too; their border carries focus. */ +.key-field input { + display: block; + width: 100%; + height: 48px; + padding: 7px 14px; + border: 1px solid var(--dsw-alias-border-l4); + border-radius: 12px; + outline: none; + background: transparent; + color: var(--dsw-alias-label-primary); + font-family: inherit; + font-size: 14px; + line-height: 24px; + user-select: text; +} + +.key-field input:focus { + border-color: var(--dsw-alias-brand-primary); +} + +.key-field input::placeholder { + color: var(--dsw-alias-label-caption); + opacity: 1; +} + +.key-error { + position: absolute; + top: 100%; + width: 100%; + margin: 8px 0 0; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 22px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/apps/desktop/renderer/welcome.html b/apps/desktop/renderer/welcome.html new file mode 100644 index 0000000000..83248438c4 --- /dev/null +++ b/apps/desktop/renderer/welcome.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + +
+ +

+ +
+

+

+
+
+ + + +
+ +
+ + +
+ +
+ + diff --git a/apps/desktop/renderer/welcome.js b/apps/desktop/renderer/welcome.js new file mode 100644 index 0000000000..13b9daeaf6 --- /dev/null +++ b/apps/desktop/renderer/welcome.js @@ -0,0 +1,113 @@ +/* Desktop entry and API-key form; credentials leave only through the narrow preload. */ +const api = window.dshWelcome +const { id, messages } = api +document.documentElement.lang = id +document.title = messages.welcomeTitle +document.querySelector('.brand').alt = messages.welcomeBrand +document.querySelector('#tagline-before').textContent = messages.welcomeTaglineBefore +document.querySelector('#tagline-brand').textContent = messages.welcomeTaglineBrand +document.querySelector('#tagline-after').textContent = messages.welcomeTaglineAfter +document.querySelector('#sign-in').textContent = messages.welcomeSignIn +document.querySelector('#api-key').textContent = messages.welcomeApiKey +document.querySelector('#key-title').textContent = messages.welcomeKeyTitle +document.querySelector('#key-description').textContent = messages.welcomeKeyDescription +document.querySelector('#key-label').textContent = messages.welcomeKeyPlaceholder +document.querySelector('#skip-key').textContent = messages.welcomeKeyLater +document.querySelector('#back-to-login').textContent = messages.welcomeKeyBack + +const form = document.querySelector('#key-form') +const input = document.querySelector('#key-input') +const error = document.querySelector('#key-error') +const save = document.querySelector('#save-key') +const skip = document.querySelector('#skip-key') +const back = document.querySelector('#back-to-login') +const welcome = document.querySelector('.welcome') +let busy = false + +input.placeholder = messages.welcomeKeyPlaceholder +save.textContent = messages.welcomeKeySave + +function showError(message) { + error.textContent = message + error.hidden = message === '' + input.setAttribute('aria-invalid', String(message !== '')) +} + +function renderBusy() { + input.disabled = busy + save.disabled = busy || input.value.trim() === '' + skip.disabled = busy + back.disabled = busy + form.setAttribute('aria-busy', String(busy)) +} + +document.querySelector('#api-key').addEventListener('click', () => { + document.querySelector('#tagline').hidden = true + document.querySelector('#entry-actions').hidden = true + form.hidden = false + document.querySelector('#key-actions').hidden = false + welcome.setAttribute('aria-labelledby', 'key-title') + welcome.classList.add('key-page') + input.focus() +}) + +back.addEventListener('click', () => { + if (busy) return + input.value = '' + showError('') + renderBusy() + form.hidden = true + document.querySelector('#key-actions').hidden = true + document.querySelector('#tagline').hidden = false + document.querySelector('#entry-actions').hidden = false + welcome.setAttribute('aria-labelledby', 'tagline') + welcome.classList.remove('key-page') + document.querySelector('#api-key').focus() +}) + +input.addEventListener('input', () => { + showError('') + renderBusy() +}) + +form.addEventListener('submit', async (event) => { + event.preventDefault() + if (busy) return + const value = input.value.trim() + if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) + || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) { + showError(value === '' ? messages.welcomeKeyBlank : messages.welcomeKeyInvalid) + input.focus() + return + } + busy = true + showError('') + renderBusy() + try { + const result = await api.saveApiKey(value) + if (!result.ok) showError(messages.welcomeKeyFailed) + else input.value = '' + } catch { + showError(messages.welcomeKeyFailed) + } finally { + busy = false + renderBusy() + } +}) + +skip.addEventListener('click', async () => { + if (busy) return + busy = true + renderBusy() + try { + await api.skip() + input.value = '' + } catch { + showError(messages.welcomeContinueFailed) + } finally { + busy = false + renderBusy() + } +}) + +renderBusy() diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 05e1bdbf0e..1ce40ff758 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -54,7 +54,7 @@ async function runPackageScript(script: string, cwd: string): Promise { await run(process.execPath, [packageManager, 'run', script], cwd) } -async function launchElectron(): Promise { +async function launchElectron(previewWelcome: boolean): Promise { const require = createRequire(import.meta.url) const electron: unknown = require('electron') if (typeof electron !== 'string') throw new Error('desktop development: electron executable is unavailable') @@ -77,6 +77,7 @@ async function launchElectron(): Promise { `--remote-debugging-port=${String(rendererPort)}`, `--user-data-dir=${userData}`, APP_ROOT, + ...previewWelcome ? ['--preview-welcome'] : [], ], APP_ROOT, environment) } @@ -110,7 +111,7 @@ async function main(): Promise { release, }) await preparePrimaryRuntime() - await launchElectron() + await launchElectron(values['preview-welcome']) } await main().catch((error: unknown) => { diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts index eddd683161..36c70baff0 100644 --- a/apps/desktop/scripts/development-project.ts +++ b/apps/desktop/scripts/development-project.ts @@ -20,6 +20,7 @@ import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runt interface PackageManifest { readonly name?: string readonly version?: string + readonly dependencies?: Readonly> } /** Inputs whose locations differ between the launcher and isolated tests. */ @@ -86,6 +87,32 @@ function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): str return names } +/** Configured workspace plugins must resolve from the profile even when pnpm does not hoist them. */ +function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot: string): string[] { + const names: string[] = [] + const visited = new Set() + const visit = (directory: string): void => { + const source = realpathSync(directory) + if (visited.has(source)) return + visited.add(source) + const manifest = readManifest(join(source, 'package.json')) + for (const [name, specifier] of Object.entries(manifest.dependencies ?? {})) { + if (!specifier.startsWith('workspace:')) continue + const dependency = join(source, 'node_modules', ...name.split('/')) + if (!existsSync(dependency)) { + throw new Error(`desktop development: ${name} is missing from ${source}; run pnpm install`) + } + const destination = join(destinationRoot, ...name.split('/')) + removeOwnedPath(destination) + linkDirectory(dependency, destination) + names.push(name) + visit(dependency) + } + } + for (const directory of roots) visit(directory) + return names +} + /** * Replace one disposable project with links to the current built workspace. * @param options - Project destination, CLI package, and release identity. @@ -117,7 +144,10 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s createDevelopmentProjectMetadata(options.projectDir, options.release) const destinationModules = join(options.projectDir, 'node_modules') mkdirSync(destinationModules, { recursive: true }) - const names = mirrorDependencyLinks(options.dependencyDir, destinationModules) + const names = [ + ...mirrorDependencyLinks(options.dependencyDir, destinationModules), + ...mirrorWorkspaceDependencies([options.cliDir, options.hostDir], destinationModules), + ] const dshLink = join(destinationModules, '@deepseek-ai', 'dsh') removeOwnedPath(dshLink) linkDirectory(options.cliDir, dshLink) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e78db93fee..ed884ae99e 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -7,6 +7,8 @@ export const DESKTOP_IPC = { boot: 'dsh-desktop:boot', bootFailed: 'dsh-desktop:boot-failed', directoryPick: 'dsh-desktop:directory-pick', + localeBootstrap: 'dsh-desktop:locale-bootstrap', + localeChanged: 'dsh-desktop:locale-changed', updatesStatus: 'dsh-desktop:updates-status', updatesOpen: 'dsh-desktop:updates-open', updatesPresentation: 'dsh-desktop:updates-presentation', diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 03e6f29a57..3043fa6be0 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -21,7 +21,35 @@ export const en = { restartApplication: 'Restart', recoveryOperationFailed: 'The recovery operation failed', disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart', + welcomeTitle: 'DeepSeek Harness', + welcomeBrand: 'DeepSeek Harness', + welcomeTaglineBefore: 'Go deeper with ', + welcomeTaglineBrand: 'DeepSeek', + welcomeTaglineAfter: '', + welcomeAuthStarting: 'Opening sign in…', + welcomeAuthWaiting: 'Continue in your browser', + welcomeAuthExchanging: 'Completing sign in…', + welcomeAuthExpired: 'Sign in timed out', + welcomeAuthExpiredDescription: 'Sign in again to continue', + welcomeAuthFailed: 'Could not complete sign in. Please try again.', + welcomeAuthReopen: 'Open browser again', + welcomeAuthCancel: 'Cancel', + welcomeAuthRetry: 'Sign in again', + welcomeSignIn: 'Sign in', + welcomeApiKey: 'Add API Key', + welcomeKeyTitle: 'Add an API key to get started', + welcomeKeyDescription: 'Configure official DeepSeek models to start using Harness', + welcomeKeyPlaceholder: 'Enter API key', + welcomeKeySave: 'Save and continue', + welcomeKeyLater: 'Set up later', + welcomeKeyBack: 'Back to sign in', + welcomeKeyBlank: 'Enter an API key.', + welcomeKeyInvalid: 'Enter the API key itself, without quotes, spaces, or an environment-variable assignment.', + welcomeKeyFailed: 'Could not save the API key. Please try again.', + welcomeContinueFailed: 'Could not open the workspace. Please try again.', checkUpdatesMenu: 'Check for Updates…', + reloadPageMenu: 'Reload Page', + restartAppHostMenu: 'Restart App and Host', updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', updateDownloadFailed: 'Could not download the update. Please try again.', @@ -112,7 +140,35 @@ export const zh = { restartApplication: '重启', recoveryOperationFailed: '恢复操作失败', disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启', + welcomeTitle: 'DeepSeek Harness', + welcomeBrand: 'DeepSeek Harness', + welcomeTaglineBefore: '与 ', + welcomeTaglineBrand: 'DeepSeek', + welcomeTaglineAfter: ' 深度对话', + welcomeAuthStarting: '正在打开登录…', + welcomeAuthWaiting: '请在浏览器中继续', + welcomeAuthExchanging: '正在完成登录…', + welcomeAuthExpired: '登录已超时', + welcomeAuthExpiredDescription: '请重新登录后继续操作', + welcomeAuthFailed: '登录未完成,请重试。', + welcomeAuthReopen: '重新打开浏览器', + welcomeAuthCancel: '取消', + welcomeAuthRetry: '重新登录', + welcomeSignIn: '登录', + welcomeApiKey: '添加 API Key', + welcomeKeyTitle: '添加一个 API Key 开始使用', + welcomeKeyDescription: '配置 DeepSeek 官方模型,即可开始使用', + welcomeKeyPlaceholder: '输入 API 密钥', + welcomeKeySave: '保存并继续', + welcomeKeyLater: '稍后配置', + welcomeKeyBack: '返回登录', + welcomeKeyBlank: '请输入 API 密钥。', + welcomeKeyInvalid: '请仅输入 API 密钥,不要包含引号、空格或环境变量赋值。', + welcomeKeyFailed: '无法保存 API 密钥,请重试。', + welcomeContinueFailed: '无法打开工作区,请重试。', checkUpdatesMenu: '检查更新…', + reloadPageMenu: '刷新页面', + restartAppHostMenu: '重启应用与 Host', updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', updateDownloadFailed: '下载更新失败,请重试。', @@ -192,6 +248,22 @@ export function resolveDesktopLocale(locale: string): DesktopLocale { : { id: 'en', messages: en } } +/** + * Choose a built-in dictionary from the shared preference, then ordered OS languages. + * @param preference - explicit locale.preference, or null when no language was selected. + * @param languages - operating-system languages in preference order. + * @returns the supported dictionary, falling back to English. + */ +export function resolveDesktopStartupLocale(preference: string | null, languages: readonly string[]): DesktopLocale { + const selected = preference?.toLowerCase() + if (selected === 'zh' || selected === 'en') return resolveDesktopLocale(selected) + for (const language of languages) { + const primary = language.toLowerCase().split('-')[0] + if (primary === 'zh' || primary === 'en') return resolveDesktopLocale(primary) + } + return resolveDesktopLocale('en') +} + /** Replace named placeholders in one locale-owned message. */ export function formatDesktopMessage( message: string, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f9bfb78409..7b0bd7f807 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,6 +12,7 @@ import { Menu, powerMonitor, nativeTheme, + net, protocol, session, shell, @@ -21,14 +22,18 @@ import { import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager } from './project-manager.ts' import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts' +import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' -import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' +import { formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' +import { openWelcomeWindow } from './welcome-window.ts' +import { WELCOME_IPC, needsWelcome } from './welcome-api.ts' +import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts' import { DesktopUpdateJournal } from './update-journal.ts' import { DesktopUpdatePreparationError } from './update-error.ts' import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts' @@ -126,8 +131,6 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi titleBarStyle: 'hiddenInset' as const, trafficLightPosition: { x: 16, y: 18 }, vibrancy: 'sidebar' as const, - // 'active' keeps the vibrancy material stable when the window blurs; - // 'followWindow' washes the sidebar out behind an unfocused window. visualEffectState: 'active' as const, backgroundColor: '#00000000', } : {}), @@ -183,6 +186,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi } async function main(): Promise { + const previewWelcome = !app.isPackaged && process.argv.includes('--preview-welcome') const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion()) const resources = runtimeResources() @@ -194,18 +198,22 @@ async function main(): Promise { let startup: Promise | undefined let workspaceRecovery: Promise | undefined let mainWindow: BrowserWindow | undefined + let welcomeWindow: BrowserWindow | undefined + let enteredWorkspace = false let shellInstallerOwnsQuit = false let requireCleanStop = false let updateStoppedHost = false let updateStopFailure: DesktopHostUncleanExitError | undefined let updateState: DesktopUpdateState = { phase: 'idle' } + const systemLanguages = app.getPreferredSystemLanguages() + let locale = resolveDesktopStartupLocale(null, systemLanguages) + windowsLanguage = locale.id let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined let mandatoryUI: DesktopMandatoryUpdateWindow | undefined let policyAuth: DesktopPolicyTestAuth | undefined const isQuitting = (): boolean => quitting const currentMainWindow = (): BrowserWindow | undefined => mainWindow const ordinaryDialogs = new Set() - const locale = resolveDesktopLocale(app.getLocale()) const messages = locale.messages const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), locale) const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true @@ -223,6 +231,11 @@ async function main(): Promise { let hostUrl: string | undefined let hostCookie: string | undefined let injections: readonly unknown[] = [] + let welcomeBackend: DesktopWelcomeBackend | undefined + let stopAccount: (() => void) | undefined + let openedAttempt: string | undefined + let returnedAttempt: string | undefined + let previousAccountStatus: string | undefined const assertProductSender = (event: IpcMainInvokeEvent): void => { assertDesktopSender(event, ['app']) if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents @@ -245,13 +258,14 @@ async function main(): Promise { navigation = next return next.promise } + const platformView = new DesktopPlatformView(join(app.getAppPath(), 'lib', 'preload-platform-account.cjs')) const backend = new DesktopBackendController((onFailure) => { const hostInspectPort = developmentHostInspectPort(development) const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, hostInspectPort, process.env, onFailure, development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime') : join(process.resourcesPath, 'runtime', 'primary-runtime'), - development ? 'link' : 'runtime', resources) + development ? 'link' : 'runtime', resources, (next) => { platformView.setSession(next) }) return { start: async () => { const ready = await host.start() @@ -259,6 +273,31 @@ async function main(): Promise { hostUrl = ready.url if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections') injections = ready.injections + welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; ')) + stopAccount?.() + stopAccount = welcomeBackend.account.watch((state) => { + if (quitting) return + if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) + const attempt = state.attempt + if (attempt?.phase === 'waiting-browser' && attempt.authorizeUrl !== undefined && openedAttempt !== attempt.id) { + openedAttempt = attempt.id + void shell.openExternal(attempt.authorizeUrl).catch(() => undefined) + } + if ((attempt?.phase === 'failed' || attempt?.phase === 'expired') && returnedAttempt !== attempt.id) { + returnedAttempt = attempt.id + focusPrimaryWindow() + } + if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace().catch(() => undefined) + if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') { + void readWelcomeState().then((value) => { + if (!value.hasApiKey && !quitting) { enteredWorkspace = false; return showWelcome() } + return undefined + }).catch(() => undefined) + } + previousAccountStatus = state.status + }, () => { + if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, { status: 'signed-out', attempt: null }) + }) }, stop: async () => { try { await host.stop(requireCleanStop) } @@ -320,6 +359,10 @@ async function main(): Promise { return state } + const readWelcomeState = async () => { + if (backend.host === undefined || welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') + return welcomeBackend.read() + } stopForRecovery = () => backend.close() const reconcileBackend = (): Promise => { @@ -328,6 +371,7 @@ async function main(): Promise { await backend.start(async () => { await manager.applyRelease(app.isPackaged) }) + if (backend.host !== undefined) await openInitialWindow() if (backend.host !== undefined) updateJournal?.action('workspace-ready') // The existing Web document resumes through the boot IPC response. })().catch((error: unknown) => { @@ -444,11 +488,49 @@ async function main(): Promise { callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } }) }) + const assertMainApplication = (event: IpcMainInvokeEvent): BrowserWindow => { + const owner = mainWindow + if (owner === undefined || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame + || !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command') + return owner + } + ipcMain.on(PLATFORM_IPC.bootstrap, (event) => { + try { event.returnValue = platformView.bootstrap(event) } + catch { event.returnValue = null } + }) + ipcMain.handle(PLATFORM_IPC.open, (event, page: unknown, bounds: unknown) => { + const owner = assertMainApplication(event) + if (page !== 'usage' && page !== 'top-up') throw new Error('Invalid Platform page') + return platformView.open(owner, page, platformBounds(bounds)) + }) + ipcMain.handle(PLATFORM_IPC.bounds, (event, bounds: unknown) => { + assertMainApplication(event) + platformView.setBounds(platformBounds(bounds)) + }) + ipcMain.handle(PLATFORM_IPC.close, (event) => { assertMainApplication(event); platformView.close() }) // Only the main window may synchronize its palette with the native material. ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => { if (mainWindow === undefined || event.sender !== mainWindow.webContents) return if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source }) + ipcMain.handle(DESKTOP_IPC.localeBootstrap, async (event) => { + if (event.sender !== mainWindow?.webContents || event.senderFrame !== mainWindow.webContents.mainFrame + || new URL(event.senderFrame.url).origin !== new URL(applicationUrl).origin) { + throw new Error('desktop welcome: rejected locale request from an unowned frame') + } + const current = await readWelcomeState() + return { languages: systemLanguages, preference: current.localePreference } + }) + ipcMain.on(DESKTOP_IPC.localeChanged, (event, next: unknown) => { + const window = mainWindow + if (window === undefined || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame + || typeof next !== 'string') return + const current = resolveDesktopStartupLocale(next, systemLanguages) + if (current.id === locale.id) return + locale = current + windowsLanguage = locale.id + installMenu() + }) ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { assertProductSender(event) return presentDesktopUpdate(updates.state) @@ -607,14 +689,26 @@ async function main(): Promise { { label: currentDesktopLocale().messages.aboutMenu, role: 'about' }, { type: 'separator' }, { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } }, + ...development ? [ + { type: 'separator' as const }, + { label: currentDesktopLocale().messages.reloadPageMenu, role: 'reload' as const }, + { label: currentDesktopLocale().messages.restartAppHostMenu, click: () => { + if (quitting) return + app.relaunch() + app.quit() + } }, + ] : [], { type: 'separator' }, ...hideCommands, { role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, ] - Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{ - label: darwin ? app.name : currentDesktopLocale().messages.application, - submenu: applicationItems(), - }, ...platformMenus])) + const installMenu = (): void => { + Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{ + label: process.platform === 'darwin' ? app.name : currentDesktopLocale().messages.application, + submenu: applicationItems(), + }, ...platformMenus])) + } + installMenu() if (process.platform === 'win32') { ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => { @@ -666,7 +760,7 @@ async function main(): Promise { } const createMainWindow = (): BrowserWindow => { - const window = createWindow(appPreload, true, true) + const window = createWindow(appPreload, false, true) mainWindow = window window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) @@ -686,13 +780,89 @@ async function main(): Promise { }) return window } + const enterWorkspace = async (): Promise => { + if (quitting) return + const window = mainWindow ?? createMainWindow() + await navigateMain(applicationUrl) + // Window events can change startup state while navigation is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (quitting || recovery.active || window.isDestroyed()) return + window.show() + enteredWorkspace = true + welcomeWindow?.close() + welcomeWindow = undefined + if (development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + window.webContents.openDevTools({ mode: 'detach' }) + } + } + let openingWelcome: Promise | undefined + const showWelcome = (): Promise => { + if (quitting) return Promise.resolve() + openingWelcome ??= (async () => { + welcomeWindow = await openWelcomeWindow(locale, { + startSignIn: async () => { + if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') + return welcomeBackend.account.start(locale.id) + }, + cancelSignIn: async (id) => { + if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') + return welcomeBackend.account.cancel(id) + }, + reopenSignIn: async (id) => { + const state = await welcomeBackend?.account.state() + if (state?.attempt?.id === id && state.attempt.phase === 'waiting-browser' && state.attempt.authorizeUrl !== undefined) { + await shell.openExternal(state.attempt.authorizeUrl) + } + }, + saveApiKey: async (apiKey) => { + if (backend.host === undefined || welcomeBackend === undefined) return { ok: false } + const saved = await welcomeBackend.save(apiKey) + if (!saved.ok) return saved + await enterWorkspace() + return { ok: true } + }, + skip: enterWorkspace, + }) + const window = welcomeWindow + window.once('closed', () => { + void welcomeBackend?.account.state().then((state) => { + if (state.attempt !== null && !enteredWorkspace) return welcomeBackend?.account.cancel(state.attempt.id) + return undefined + }).catch(() => undefined) + }) + window.once('closed', () => { + if (welcomeWindow === window) welcomeWindow = undefined + if (!enteredWorkspace && !recovery.active) mainWindow?.close() + }) + // Quit and backend failure callbacks can run while the welcome document loads. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (quitting || recovery.active) window.close() + else mainWindow?.hide() + })().finally(() => { openingWelcome = undefined }) + return openingWelcome + } + const openInitialWindow = async (): Promise => { + if (quitting || recovery.active) return + const state = await readWelcomeState() + // Quit or a backend failure can occur while the Host answers the preference request. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (quitting || backend.state.phase !== 'ready') return + locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages) + windowsLanguage = locale.id + installMenu() + if (!enteredWorkspace && (previewWelcome || needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey }))) { + await showWelcome() + } else { + await enterWorkspace() + } + } focusPrimaryWindow = () => { if (quitting) return if (isMandatory()) { mandatoryUI?.focus(); return } - const window = mainWindow + const window = welcomeWindow ?? mainWindow if (window === undefined || window.isDestroyed()) { try { createMainWindow() } catch (error) { reportFatal(error); return } - void navigateMain(applicationUrl).catch(reportFatal) + void (backend.state.phase === 'ready' ? openInitialWindow() : navigateMain(applicationUrl)).catch(reportFatal) return } if (window.isMinimized()) window.restore() @@ -700,6 +870,12 @@ async function main(): Promise { window.focus() } + if (app.isPackaged || process.env.DSH_DESKTOP_DEV_APP === '1') app.setAsDefaultProtocolClient('dsh') + app.on('open-url', (event, url) => { + event.preventDefault() + if (url === 'dsh://open' || url === 'dsh://open/') focusPrimaryWindow() + }) + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow() }) @@ -717,6 +893,8 @@ async function main(): Promise { if (quitting) return event.preventDefault() quitting = true + stopAccount?.() + if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide() if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide() updateSchedule.dispose() updateDialog.dispose() diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 26b7334707..622769cb18 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' +import { PLATFORM_IPC } from './platform-ipc.ts' import { markDocumentPlatform } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' import { syncWindowsAppearance } from './preload-windows.ts' @@ -28,9 +29,21 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise, failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise, }) + contextBridge.exposeInMainWorld('dshPlatform', { + open: (page: 'usage' | 'top-up', bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.invoke(PLATFORM_IPC.open, page, bounds), + setBounds: (bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.invoke(PLATFORM_IPC.bounds, bounds), + close: () => ipcRenderer.invoke(PLATFORM_IPC.close), + }) } markDocumentPlatform() syncNativeTheme() // Main-process IPC also verifies the owning window and top frame. contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? product : { protocolVersion: 1 }) + +if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { + contextBridge.exposeInMainWorld('__DSH_LOCALE__', { + read: () => ipcRenderer.invoke(DESKTOP_IPC.localeBootstrap), + onChange: (locale: string) => { ipcRenderer.send(DESKTOP_IPC.localeChanged, locale) }, + }) +} diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts new file mode 100644 index 0000000000..a0ad499dc6 --- /dev/null +++ b/apps/desktop/src/preload-welcome.ts @@ -0,0 +1,15 @@ +/** Localized welcome copy and write-only credential actions. */ + +import { contextBridge, ipcRenderer } from 'electron' +import { resolveDesktopLocale } from './locale.ts' +import { WELCOME_IPC, type WelcomeApi, type WelcomeSaveResult } from './welcome-api.ts' + +const prefix = '--dsh-welcome-locale=' +const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice(prefix.length) +if (locale === undefined) throw new Error('desktop welcome: missing window locale') +const api: WelcomeApi = { + ...resolveDesktopLocale(locale), + saveApiKey: (value: string) => ipcRenderer.invoke(WELCOME_IPC.saveApiKey, value) as Promise, + skip: () => ipcRenderer.invoke(WELCOME_IPC.skip) as Promise, +} +contextBridge.exposeInMainWorld('dshWelcome', api) diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts new file mode 100644 index 0000000000..85e494ea2c --- /dev/null +++ b/apps/desktop/src/welcome-api.ts @@ -0,0 +1,45 @@ +/** Operations available to the isolated native welcome renderer. */ + +import type { DesktopLocale } from './locale.ts' + +/** Private native welcome channels, installed only while its window exists. */ +export const WELCOME_IPC = { + saveApiKey: 'dsh-welcome:save-api-key', + skip: 'dsh-welcome:skip', +} as const + +/** Credential writes return a safe outcome without exposing Host diagnostics. */ +export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false } + +/** Host-owned operations used by the welcome window. */ +export interface WelcomeOperations { + /** + * Store the official provider's key before entering the workspace. + * @param value - validated, trimmed API key. + * @returns whether the write completed, without private error details. + */ + saveApiKey(value: string): Promise + /** + * Enter the workspace without writing an onboarding-completion setting. + * @returns completion after the workspace opens. + */ + skip(): Promise +} + +/** The renderer receives localized copy and two narrowly scoped operations. */ +export type WelcomeApi = DesktopLocale & WelcomeOperations + +/** Authentication facts supplied at cold start or after a completed sign-out. */ +export interface WelcomeAuthentication { + readonly loggedIn: boolean + readonly hasApiKey: boolean +} + +/** + * Decide whether a startup or sign-out requires the welcome entry. + * @param authentication - current account and independently stored API-key facts. + * @returns true only when neither authentication route is configured. + */ +export function needsWelcome(authentication: WelcomeAuthentication): boolean { + return !authentication.loggedIn && !authentication.hasApiKey +} diff --git a/apps/desktop/src/welcome-backend.ts b/apps/desktop/src/welcome-backend.ts new file mode 100644 index 0000000000..2af24c19ae --- /dev/null +++ b/apps/desktop/src/welcome-backend.ts @@ -0,0 +1,117 @@ +/** Native welcome operations using the shared Web authentication and RPC APIs. */ + +import { randomUUID } from 'node:crypto' + +/** Metadata needed before the native entry or workspace becomes visible. */ +export interface WelcomeState { + readonly hasApiKey: boolean + readonly writable: boolean + readonly localePreference: string | null +} + +/** Narrow operations available to the native welcome flow. */ +export interface DesktopWelcomeBackend { + /** @returns Configured-key presence and the shared language preference, without credential values. */ + read(): Promise + /** + * @param apiKey - User-entered official provider key. + * @returns A safe write outcome without provider diagnostics. + */ + save(apiKey: string): Promise<{ ok: boolean }> +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Authenticate the native HTTP client through the Web application's launch URL. + * @param authenticatedUrl - URL supplied by the running Desktop Host. + * @param send - Electron session fetch, retaining the Web authentication cookie. + * @returns metadata reads and write-only credential operations over standard RPC. + */ +export async function connectDesktopWelcome( + authenticatedUrl: string, + send: (input: string, init?: RequestInit) => Promise, +): Promise { + const origin = new URL(authenticatedUrl).origin + const authenticated = await send(authenticatedUrl, { credentials: 'include' }) + await authenticated.body?.cancel() + if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed') + const invoke = async (request: { namespace: string; method: string; args: Record }): Promise => { + const rpcId = randomUUID() + const method = `${request.namespace}/${request.method}` + const response = await send(new URL(`/api/${method}`, origin).href, { + method: 'POST', credentials: 'include', redirect: 'error', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }), + }) + if (!response.ok) throw new Error('desktop welcome: Web request failed') + const envelope: unknown = await response.json() + if (!record(envelope) || envelope.type !== 'server-response' || envelope.rpcId !== rpcId + || !record(envelope.result) || envelope.result.ok !== true) { + throw new Error('desktop welcome: Web RPC failed') + } + return envelope.result.value + } + const settingsAndReference = async () => { + const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} }) + if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces') + const official = settings.namespaces.find((item: unknown) => record(item) && item.ns === 'llm-deepseek') as unknown + if (!record(official) || !record(official.value) || typeof official.value.apiKeyEnv !== 'string') { + throw new Error('desktop welcome: missing official DeepSeek credential reference') + } + return { settings: { namespaces: settings.namespaces }, ref: official.value.apiKeyEnv } + } + const read = async (): Promise => { + const { settings, ref } = await settingsAndReference() + const providers = await invoke({ namespace: 'llm', method: 'listConfigurableProviders', args: {} }) + if (!Array.isArray(providers)) throw new Error('desktop welcome: invalid provider directory') + const namespaces = settings.namespaces + const refs = providers.flatMap((provider: unknown) => { + if (!record(provider) || typeof provider.settingsNs !== 'string' || !Array.isArray(provider.settingsPath)) { + throw new Error('desktop welcome: invalid provider settings address') + } + const namespace = namespaces.find((item: unknown) => record(item) && item.ns === provider.settingsNs) as unknown + let value: unknown = record(namespace) ? namespace.value : undefined + for (const key of provider.settingsPath as unknown[]) { + if (typeof key !== 'string') throw new Error('desktop welcome: invalid provider settings path') + value = record(value) ? value[key] : undefined + } + return record(value) && typeof value.apiKeyEnv === 'string' ? [value.apiKeyEnv] : [] + }) + const unique = [...new Set([ref, ...refs])] + const states: Record = {} + // credentials.describe accepts at most 64 references per request. + for (let offset = 0; offset < unique.length; offset += 64) { + const batch = await invoke({ namespace: 'credentials', method: 'describe', args: { refs: unique.slice(offset, offset + 64) } }) + if (!record(batch)) throw new Error('desktop welcome: invalid credential metadata') + Object.assign(states, batch) + } + if (!record(states[ref])) throw new Error('desktop welcome: missing credential metadata') + const locale = namespaces.find((item: unknown) => record(item) && item.ns === 'locale') as unknown + if (!record(locale) || !record(locale.value) + || (locale.value.preference !== undefined && typeof locale.value.preference !== 'string')) { + throw new Error('desktop welcome: invalid locale preference') + } + return { + hasApiKey: Object.values(states).some(value => record(value) && value.configured === true), + writable: states[ref].writable === true, + localePreference: locale.value.preference ?? null, + } + } + return { + read, + async save(apiKey) { + if (!/^[\x21-\x7e]+$/.test(apiKey)) return { ok: false } + try { + const { ref } = await settingsAndReference() + await invoke({ namespace: 'credentials', method: 'set', args: { ref, value: apiKey } }) + return { ok: true } + } catch { + // Provider diagnostics may contain credentials; the native form owns failure copy. + return { ok: false } + } + }, + } +} diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts new file mode 100644 index 0000000000..bf8596b490 --- /dev/null +++ b/apps/desktop/src/welcome-window.ts @@ -0,0 +1,86 @@ +/** Native welcome window and its presentation-only renderer. */ + +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { app, BrowserWindow, ipcMain, type BrowserWindowConstructorOptions, type IpcMainInvokeEvent } from 'electron' +import type { DesktopLocale } from './locale.ts' +import { WELCOME_IPC, type WelcomeOperations } from './welcome-api.ts' + +/** + * Resolve the fixed-size welcome window's native material and controls. + * @param platform - operating system hosting Electron. + * @param locale - shell-owned localized copy. + * @returns sandboxed window options with a locale-only preload. + */ +export function welcomeWindowOptions(platform: NodeJS.Platform, locale: DesktopLocale): BrowserWindowConstructorOptions { + return { + width: 600, + height: 700, + useContentSize: true, + center: true, + resizable: false, + maximizable: false, + fullscreenable: false, + show: false, + title: locale.messages.welcomeTitle, + backgroundColor: platform === 'darwin' || platform === 'win32' ? '#00000000' : '#FFFFFF', + ...(platform === 'darwin' ? { + titleBarStyle: 'hidden', + trafficLightPosition: { x: 16, y: 21 }, + vibrancy: 'titlebar', + visualEffectState: 'active', + } as const : {}), + ...(platform === 'win32' ? { + titleBarStyle: 'hidden', + titleBarOverlay: { color: '#00000000', symbolColor: '#0F1115', height: 42 }, + backgroundMaterial: 'acrylic', + } as const : {}), + webPreferences: { + preload: fileURLToPath(new URL('./preload-welcome.cjs', import.meta.url)), + additionalArguments: [`--dsh-welcome-locale=${locale.id}`], + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + }, + } +} + +/** + * Open the process's sole welcome window with desktop-owned operations. + * The caller closes an existing welcome window before opening another. + * @param locale - shell-owned localized copy. + * @param operations - credential write and this-launch-only skip actions. + * @returns the visible window; a failed load destroys it before rejecting. + */ +export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise { + const window = new BrowserWindow(welcomeWindowOptions(process.platform, locale)) + const assertSender = (event: IpcMainInvokeEvent): void => { + if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) { + throw new Error('desktop welcome: rejected action from an unowned frame') + } + } + ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => { + assertSender(event) + if (typeof value !== 'string' || !/^[\x21-\x7e]+$/.test(value)) return { ok: false } + return operations.saveApiKey(value) + }) + ipcMain.handle(WELCOME_IPC.skip, async (event) => { + assertSender(event) + await operations.skip() + }) + window.once('closed', () => { + ipcMain.removeHandler(WELCOME_IPC.saveApiKey) + ipcMain.removeHandler(WELCOME_IPC.skip) + }) + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + window.webContents.on('will-navigate', (event) => { event.preventDefault() }) + try { + await window.loadFile(join(app.getAppPath(), 'renderer', 'welcome.html')) + } catch (error) { + if (!window.isDestroyed()) window.destroy() + throw error + } + if (!window.isDestroyed()) window.show() + return window +} diff --git a/apps/desktop/tests/development-project.spec.ts b/apps/desktop/tests/development-project.spec.ts index 0fe54ab1a3..07e0a0a5ca 100644 --- a/apps/desktop/tests/development-project.spec.ts +++ b/apps/desktop/tests/development-project.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -31,6 +31,27 @@ afterEach(() => { }) describe('desktop development project', () => { + it('includes declared workspace packages missing from the hoist directory in the runtime inventory', () => { + const root = temporaryRoot() + const cli = join(root, 'cli') + const host = join(root, 'host') + const dependency = join(root, 'unhoisted') + const hoisted = join(root, 'hoisted') + mkdirSync(join(cli, 'node_modules'), { recursive: true }) + mkdirSync(join(host, 'lib'), { recursive: true }) + mkdirSync(dependency) + mkdirSync(hoisted) + writeFileSync(join(cli, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh', version: '1.2.3', dependencies: { unhoisted: 'workspace:^' } })) + writeFileSync(join(host, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-desktop-host', version: '1.2.3' })) + writeFileSync(join(host, 'lib/index.js'), '') + writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'unhoisted', version: '1.2.3' })) + symlinkSync(dependency, join(cli, 'node_modules/unhoisted'), process.platform === 'win32' ? 'junction' : 'dir') + const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release() }) + expect(realpathSync(join(project, 'node_modules/unhoisted'))).toBe(realpathSync(dependency)) + const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { sharedPackages: unknown[] } + expect(descriptor.sharedPackages).toContainEqual({ name: 'unhoisted', version: '1.2.3', path: 'node_modules/unhoisted' }) + }) + it('manages development plugins without modifying the linked workspace packages', async () => { const root = temporaryRoot() const cli = join(root, 'apps', 'cli') diff --git a/apps/desktop/tests/expected/welcome/en-api-key.expected.txt b/apps/desktop/tests/expected/welcome/en-api-key.expected.txt new file mode 100644 index 0000000000..25c5a624b5 --- /dev/null +++ b/apps/desktop/tests/expected/welcome/en-api-key.expected.txt @@ -0,0 +1,8 @@ +DeepSeek Harness +DeepSeek Harness +Add an API key to get started +Configure official DeepSeek models to start using Harness +Enter API key [password] +Save and continue [disabled] +Set up later +Back to sign in diff --git a/apps/desktop/tests/expected/welcome/en.expected.txt b/apps/desktop/tests/expected/welcome/en.expected.txt new file mode 100644 index 0000000000..b23e770617 --- /dev/null +++ b/apps/desktop/tests/expected/welcome/en.expected.txt @@ -0,0 +1,5 @@ +DeepSeek Harness +DeepSeek Harness +Go deeper with DeepSeek +Sign in [disabled] +Add API Key diff --git a/apps/desktop/tests/expected/welcome/zh-CN-api-key.expected.txt b/apps/desktop/tests/expected/welcome/zh-CN-api-key.expected.txt new file mode 100644 index 0000000000..676a5505e8 --- /dev/null +++ b/apps/desktop/tests/expected/welcome/zh-CN-api-key.expected.txt @@ -0,0 +1,8 @@ +DeepSeek Harness +DeepSeek Harness +添加一个 API Key 开始使用 +配置 DeepSeek 官方模型,即可开始使用 +输入 API 密钥 [password] +保存并继续 [disabled] +稍后配置 +返回登录 diff --git a/apps/desktop/tests/expected/welcome/zh-CN.expected.txt b/apps/desktop/tests/expected/welcome/zh-CN.expected.txt new file mode 100644 index 0000000000..5c82dce711 --- /dev/null +++ b/apps/desktop/tests/expected/welcome/zh-CN.expected.txt @@ -0,0 +1,5 @@ +DeepSeek Harness +DeepSeek Harness +与 DeepSeek 深度对话 +登录 [disabled] +添加 API Key diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts index 99dde404aa..2bdd6673bd 100644 --- a/apps/desktop/tests/locale.spec.ts +++ b/apps/desktop/tests/locale.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts' +import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, zh } from '../src/locale.ts' describe('desktop locale dictionaries', () => { it('ships the same key set in English and Chinese', () => { @@ -14,4 +14,14 @@ describe('desktop locale dictionaries', () => { .toBe('plugin@1.2.3 {missing}') }) + it('prefers an explicit supported choice, then the first supported system language', () => { + expect(resolveDesktopStartupLocale('zh', ['en-US']).id).toBe('zh-CN') + expect(resolveDesktopStartupLocale('EN', ['zh-CN']).id).toBe('en') + expect(resolveDesktopStartupLocale(null, ['ja-JP', 'zh-Hant', 'en-US']).id).toBe('zh-CN') + expect(resolveDesktopStartupLocale(null, ['en-US', 'zh-CN']).id).toBe('en') + expect(resolveDesktopStartupLocale(null, ['ja-JP']).id).toBe('en') + expect(resolveDesktopStartupLocale(null, []).id).toBe('en') + expect(resolveDesktopStartupLocale('ja', ['zh-CN']).id).toBe('zh-CN') + }) + }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index d68d076971..f741510e20 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,3 +1,4 @@ +import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { IpcMainInvokeEvent } from 'electron' @@ -96,6 +97,7 @@ const harness = await vi.hoisted(async () => { class FakeHost { readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false) url = 'http://127.0.0.1:3080/?token=test' + fetch = vi.fn(async () => Response.json({ hasApiKey: true, writable: true, localePreference: null })) readonly ready = deferred() readonly exited = deferred() readonly stopping = deferred() @@ -117,10 +119,12 @@ const harness = await vi.hoisted(async () => { name: 'Desktop test', whenReady: () => Promise.resolve(), getLocale: (): string => 'en-US', + getPreferredSystemLanguages: () => ['en-US'], getVersion: () => '1.0.0', getAppPath: () => 'desktop-test-app', setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(), requestSingleInstanceLock: () => true, + setAsDefaultProtocolClient: vi.fn(), exit: vi.fn(), relaunch: vi.fn(), quit: vi.fn(() => { @@ -132,10 +136,17 @@ const harness = await vi.hoisted(async () => { } }), }) + let accountListener: ((state: AccountView) => void) | undefined return { failWindow(error: Error) { windowFailure = error }, windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall, + + watchAccount: (listener: (state: AccountView) => void) => { + accountListener = listener + return () => { accountListener = undefined } + }, + publishAccount(state: AccountView) { accountListener?.(state) }, ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(), get updateState() { return updateState }, set updateState(value: DesktopUpdateState) { updateState = value }, @@ -163,6 +174,7 @@ const harness = await vi.hoisted(async () => { set pluginsEnabled(value: boolean) { pluginsEnabled = value }, set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value }, reset() { + accountListener = undefined windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() powerMonitor.removeAllListeners() app.isPackaged = true @@ -198,6 +210,7 @@ vi.mock('electron', () => ({ dialog: harness.dialog, shell: { openExternal: harness.openExternal }, nativeTheme: { themeSource: 'system' }, + net: { fetch: vi.fn() }, ipcMain: { on: harness.ipcOn, handle: (channel: string, handler: InvokeHandler) => { @@ -252,6 +265,13 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class readonly install = harness.updateInstall readonly dispose = vi.fn() } })) +vi.mock('../src/welcome-backend.ts', () => ({ + connectDesktopWelcome: async () => ({ + read: async (): Promise => (await harness.hosts.at(-1)!.fetch()).json() as Promise, + save: async () => ({ ok: true }), + account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) }, + }), +})) function invoke(channel: string, origin = channel === DESKTOP_IPC.boot ? 'app' : 'shell', ...args: unknown[]): unknown { const handler = harness.handlers.get(channel) @@ -291,6 +311,7 @@ beforeEach(() => { vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' }) vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) + vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined) vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined) }) @@ -1301,12 +1322,13 @@ describe('desktop main startup', () => { expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) }) - it('shows the loading window before profile preparation and starts one actual Host', async () => { + it('prepares recovery offscreen and starts one Host before choosing the first visible window', async () => { await import('../src/main.ts') await harness.preparing.promise expect(harness.windows).toHaveLength(1) const window = harness.windows[0]! - expect(window.options.show).toBe(true) + expect(window.options.show).toBe(false) + expect(window.show).not.toHaveBeenCalled() expect(window.urls).toEqual(['dsh-app://app/']) expect(harness.hosts).toHaveLength(0) const retry = invoke(DESKTOP_IPC.boot) @@ -1361,6 +1383,27 @@ describe('desktop main startup', () => { expect(harness.hosts).toHaveLength(1) }) + it('keeps recovery visible when the backend fails during the welcome preference read', async () => { + const preferences = Promise.withResolvers() + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const host = harness.hosts[0]! + host.fetch.mockReturnValueOnce(preferences.promise) + const startup = expect(Promise.resolve(invoke(DESKTOP_IPC.boot))).rejects.toThrow('Desktop Host is unavailable') + host.ready.resolve() + await vi.waitFor(() => { expect(host.fetch).toHaveBeenCalledOnce() }) + host.exited.resolve() + host.onFailure!(new Error('backend exited during startup preferences')) + await harness.dialogShown.promise + preferences.resolve(Response.json({ hasApiKey: true, localePreference: null })) + await startup + expect(harness.windows[0]!.urls).not.toContain('http://127.0.0.1:3080/?token=test') + expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('backend exited during startup preferences') + expect(harness.windows[0]!.show).not.toHaveBeenCalled() + }) + it('waits for a pending child to exit on quit without late window navigation', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -1381,3 +1424,21 @@ describe('desktop main startup', () => { expect(harness.windows).toHaveLength(1) }) }) + +it.each(['failed', 'expired'] as const)('focuses DSH once when browser authorization becomes %s', async (phase) => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.boot)) + const window = harness.windows[0]! + window.focus.mockClear() + const state: AccountView = { + status: 'signed-out', links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' }, + attempt: { id: 'test-failed-attempt' as NonNullable['id'], phase }, + } + harness.publishAccount(state) + harness.publishAccount(state) + expect(window.focus).toHaveBeenCalledTimes(1) +}) diff --git a/apps/desktop/tests/preload.e2e.ts b/apps/desktop/tests/preload.e2e.ts new file mode 100644 index 0000000000..62c4c26889 --- /dev/null +++ b/apps/desktop/tests/preload.e2e.ts @@ -0,0 +1,37 @@ +/** Built preloads must execute with only Electron's sandbox-supported require. */ +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' + +const preload = (name: string): string => fileURLToPath(new URL(`../lib/${name}.cjs`, import.meta.url)) + +describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop preloads', () => { + it.each(['preload', 'preload-app', 'preload-welcome'])('%s loads without filesystem module access', (name) => { + const exposed = new Map>() + const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' })) + const send = vi.fn() + const electron = { + contextBridge: { exposeInMainWorld: (key: string, value: Record) => { exposed.set(key, value) } }, + ipcRenderer: { invoke, send, on: vi.fn(), off: vi.fn() }, + } + runInNewContext(readFileSync(preload(name), 'utf8'), { + require: (id: string) => { + if (id !== 'electron') throw new Error(`sandbox cannot load ${id}`) + return electron + }, + process: { argv: ['electron', '--dsh-welcome-locale=en'] }, + location: new URL('http://127.0.0.1:3080/'), + exports: {}, + }) + if (name === 'preload-app') { + const bridge = exposed.get('__DSH_LOCALE__') as { read(): unknown; onChange(locale: string): void } + bridge.read() + expect(invoke).toHaveBeenCalledWith('dsh-desktop:locale-bootstrap') + bridge.onChange('zh') + expect(send).toHaveBeenCalledWith('dsh-desktop:locale-changed', 'zh') + } else { + expect(exposed.has(name === 'preload' ? 'dshDesktop' : 'dshWelcome')).toBe(true) + } + }) +}) diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts new file mode 100644 index 0000000000..a127539ffe --- /dev/null +++ b/apps/desktop/tests/welcome-flow.e2e.ts @@ -0,0 +1,92 @@ +/** Built Desktop Host acceptance; run after the repository build, without provider credentials. */ + +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DesktopHostProcess } from '../src/host-process.ts' +import { DesktopProjectManager } from '../src/project-manager.ts' +import { resolveDesktopPaths } from '../src/paths.ts' +import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { prepareDevelopmentProject } from '../scripts/development-project.ts' + +const repository = fileURLToPath(new URL('../../../', import.meta.url)) +const builtHost = join(repository, 'apps/desktop-host/lib/index.js') +afterEach(() => { vi.unstubAllEnvs() }) + +function version(path: string): string { + return (JSON.parse(readFileSync(path, 'utf8')) as { version: string }).version +} + +describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { + it('keeps an unconfigured launch unconfigured and persists an explicitly saved key across restarts', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-welcome-')) + let host: DesktopHostProcess | undefined + try { + for (const name of Object.keys(process.env)) { + if (/KEY|TOKEN|SECRET|PASSWORD/u.test(name)) vi.stubEnv(name, undefined) + } + const home = join(root, 'home') + mkdirSync(home) + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('DSH_TELEMETRY_MODE', 'DISABLED') + const project = prepareDevelopmentProject({ + projectDir: join(root, 'project'), + cliDir: join(repository, 'apps/cli'), + hostDir: join(repository, 'apps/desktop-host'), + dependencyDir: join(repository, 'node_modules/.pnpm/node_modules'), + release: { + schemaVersion: 1, + version: version(join(repository, 'apps/desktop/package.json')), + pnpmVersion: version(join(repository, 'apps/desktop/node_modules/pnpm/package.json')), + nodeVersion: process.versions.node, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + }, + }) + const paths = resolveDesktopPaths(home) + const manager = new DesktopProjectManager(paths, { + node: process.execPath, pnpm: join(repository, 'apps/desktop/node_modules/pnpm/bin/pnpm.mjs'), dsh: project, + }) + await manager.applyRelease() + writeFileSync(join(paths.profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n') + let backend: DesktopWelcomeBackend + const restart = async (): Promise => { + await host?.stop() + host = new DesktopHostProcess(process.execPath, project, paths.profile) + const { url } = await host.start() + let cookie = '' + const send: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers) + if (cookie !== '') headers.set('cookie', cookie) + const response = await fetch(input, { ...init, headers, redirect: 'manual' }) + if (response.status !== 303) return response + cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ') + await response.body?.cancel() + return fetch(new URL(response.headers.get('location')!, url), { headers: { cookie } }) + } + backend = await connectDesktopWelcome(url, send) + } + const status = async () => backend.read() + const fingerprint = (): string => createHash('sha256') + .update(readFileSync(join(home, '.credentials.yaml'))).digest('hex') + await restart() + expect(await status()).toMatchObject({ hasApiKey: false, localePreference: null }) + const before = fingerprint() + await host!.stop() + writeFileSync(join(home, 'settings.yaml'), 'locale:\n preference: zh\n') + await restart() + expect(await status()).toMatchObject({ hasApiKey: false, localePreference: 'zh' }) + expect(fingerprint()).toBe(before) + expect(await backend!.save('sk-local-onboarding-test')).toEqual({ ok: true }) + expect(await status()).toMatchObject({ hasApiKey: true, localePreference: 'zh' }) + await restart() + expect(await status()).toMatchObject({ hasApiKey: true }) + } finally { + await host?.stop() + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/desktop/tests/welcome-host.spec.ts b/apps/desktop/tests/welcome-host.spec.ts new file mode 100644 index 0000000000..6ff6da1577 --- /dev/null +++ b/apps/desktop/tests/welcome-host.spec.ts @@ -0,0 +1,76 @@ +/** Native onboarding reuses authenticated Web RPC and never reads credential values. */ +import { describe, expect, it, vi } from 'vitest' +import { connectDesktopWelcome } from '../src/welcome-backend.ts' + +function transport(preference?: string) { + const keys = new Map() + const namespaces = [ + { ns: 'llm-deepseek', value: { apiKeyEnv: 'CUSTOM_DEEPSEEK_KEY' } }, + { ns: 'llm-pi-ai', value: { profiles: { example: { apiKeyEnv: 'EXAMPLE_API_KEY' } } } }, + { ns: 'locale', value: preference === undefined ? {} : { preference } }, + ] + const send = vi.fn[1]>(async (_input, init) => { + if (init?.method !== 'POST') return new Response('index') + const { rpcId, method, payload } = JSON.parse(init.body as string) as { + rpcId: string + method: string + payload: { args: { ref: string; value: string; refs: string[] } } + } + let value: unknown + if (method === 'settings/describe') value = { namespaces } + else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }] + else if (method === 'credentials/set') keys.set(payload.args.ref, payload.args.value) + else value = Object.fromEntries(payload.args.refs.map(ref => [ref, { configured: keys.has(ref), writable: true }])) + return Response.json({ type: 'server-response', rpcId, result: { ok: true, value } }) + }) + return { send, keys } +} + +const url = 'http://127.0.0.1:19387/?token=fixture' + +describe('desktop welcome Web operations', () => { + it('authenticates through Web and stores only through the configured credential reference', async () => { + const host = transport() + const backend = await connectDesktopWelcome(url, host.send) + expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' }) + expect(await backend.save('sk-example')).toEqual({ ok: true }) + expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example') + expect(await backend.read()).toEqual({ hasApiKey: true, writable: true, localePreference: null }) + for (const [input, init] of host.send.mock.calls.slice(1)) { + expect(input).toMatch(/^http:\/\/127\.0\.0\.1:19387\/api\//u) + expect(init).toMatchObject({ credentials: 'include', redirect: 'error' }) + } + }) + + it('reads the explicit language preference and recognizes another provider key', async () => { + const host = transport('zh') + host.keys.set('EXAMPLE_API_KEY', 'sk-other') + const backend = await connectDesktopWelcome(url, host.send) + expect(await backend.read()).toMatchObject({ localePreference: 'zh', hasApiKey: true }) + host.keys.clear() + expect(await backend.read()).toMatchObject({ hasApiKey: false }) + expect(host.send.mock.calls.every(([, init]) => !(init?.body as string | undefined)?.includes('credentials/set'))).toBe(true) + }) + + it.each(['', 'bad key', 'key\n'])('rejects malformed keys without contacting a provider: %s', async (value) => { + const host = transport() + const backend = await connectDesktopWelcome(url, host.send) + host.send.mockClear() + expect(await backend.save(value)).toEqual({ ok: false }) + expect(host.send).not.toHaveBeenCalled() + }) + + it('keeps provider diagnostics out of failures and rejects unmatched RPC envelopes', async () => { + const host = transport() + const backend = await connectDesktopWelcome(url, host.send) + host.send.mockRejectedValueOnce(new Error('private credential sk-must-not-leak')) + expect(await backend.save('sk-example')).toEqual({ ok: false }) + host.send.mockResolvedValueOnce(Response.json({ type: 'server-response', rpcId: 'other', result: { ok: true } })) + await expect(backend.read()).rejects.toThrow('Web RPC failed') + }) + + it('refuses an unauthenticated Web launch', async () => { + const send = vi.fn[1]>(async () => new Response(null, { status: 401 })) + await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed') + }) +}) diff --git a/apps/desktop/tests/welcome-renderer.spec.ts b/apps/desktop/tests/welcome-renderer.spec.ts new file mode 100644 index 0000000000..0171d42e51 --- /dev/null +++ b/apps/desktop/tests/welcome-renderer.spec.ts @@ -0,0 +1,150 @@ +import { readFileSync } from 'node:fs' +import { JSDOM } from 'jsdom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveDesktopLocale } from '../src/locale.ts' +import type { WelcomeSaveResult } from '../src/welcome-api.ts' + +const html = readFileSync(new URL('../renderer/welcome.html', import.meta.url), 'utf8') +const script = readFileSync(new URL('../renderer/welcome.js', import.meta.url), 'utf8') +const opened: JSDOM[] = [] +afterEach(() => { for (const dom of opened.splice(0)) dom.window.close() }) + +function mount(language = 'zh-CN') { + const dom = new JSDOM(html, { runScripts: 'outside-only' }) + opened.push(dom) + const api = { + ...resolveDesktopLocale(language), + saveApiKey: vi.fn<(value: string) => Promise>().mockResolvedValue({ ok: true }), + skip: vi.fn<() => Promise>().mockResolvedValue(undefined), + } + Object.defineProperty(dom.window, 'dshWelcome', { value: api }) + dom.window.eval(script) + const document = dom.window.document + const input = document.querySelector('input')! + const button = (id: string) => document.querySelector(id)! + const enterKey = (value: string) => { + input.value = value + input.dispatchEvent(new dom.window.Event('input', { bubbles: true })) + } + const submit = () => document.querySelector('form')!.dispatchEvent(new dom.window.Event('submit', { cancelable: true })) + const copy = () => { + const heading = document.querySelector('main')!.getAttribute('aria-labelledby')! + return [ + document.title, document.querySelector('img')!.alt, document.getElementById(heading)!.textContent, + ...heading === 'key-title' ? [document.querySelector('#key-description')!.textContent, `${input.placeholder} [password]`] : [], + ...[...document.querySelectorAll('button')].filter(item => item.closest('[hidden]') === null) + .map(item => `${item.textContent}${item.disabled ? ' [disabled]' : ''}`), + '', + ].join('\n') + } + return { document, api, input, button, enterKey, submit, copy } +} + +describe('desktop welcome presentation', () => { + it.each(['zh-CN', 'en'])('renders the %s entry and API-key step', async (language) => { + const view = mount(language) + expect(view.document.documentElement.lang).toBe(language) + expect(view.document.querySelector('img')!.getAttribute('src')).toBe('assets/welcome-brand.svg') + await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`) + view.button('#api-key').click() + expect(view.document.activeElement).toBe(view.input) + expect(view.input.type).toBe('password') + await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}-api-key.expected.txt`) + }) + + it('sends one trimmed key, prevents competing actions, and clears it after saving', async () => { + const view = mount() + const saved = Promise.withResolvers() + view.api.saveApiKey.mockReturnValue(saved.promise) + view.button('#api-key').click() + view.enterKey(' sk-desktop-example ') + view.submit() + view.submit() + view.button('#skip-key').click() + view.button('#back-to-login').click() + expect(view.api.saveApiKey).toHaveBeenCalledExactlyOnceWith('sk-desktop-example') + expect(view.api.skip).not.toHaveBeenCalled() + expect(view.button('#save-key').disabled).toBe(true) + expect(view.button('#save-key').textContent).toBe(view.api.messages.welcomeKeySave) + expect(view.button('#back-to-login').disabled).toBe(true) + expect(view.document.querySelector('#key-form')!.hidden).toBe(false) + saved.resolve({ ok: true }) + await vi.waitFor(() => { expect(view.input.value).toBe('') }) + expect(view.document.body.textContent).not.toContain('sk-desktop-example') + }) + + it.each(['', 'bad key', '密钥', 'DEEPSEEK_API_KEY=sk-example', '"sk-example"', '`sk-example`'])( + 'rejects invalid input before sending it: %s', (value) => { + const view = mount() + view.button('#api-key').click() + view.enterKey(value) + view.submit() + expect(view.api.saveApiKey).not.toHaveBeenCalled() + expect(view.document.querySelector('#key-error')!.hidden).toBe(false) + expect(view.input.getAttribute('aria-invalid')).toBe('true') + }, + ) + + it('retains an unsaved draft and allows retry after a refused save', async () => { + const view = mount() + view.api.saveApiKey.mockResolvedValue({ ok: false }) + view.button('#api-key').click() + view.enterKey('sk-retry') + view.submit() + await vi.waitFor(() => { expect(view.button('#save-key').disabled).toBe(false) }) + expect(view.input.value).toBe('sk-retry') + expect(view.document.querySelector('#key-error')!.textContent).toBe(view.api.messages.welcomeKeyFailed) + view.api.saveApiKey.mockResolvedValue({ ok: true }) + view.submit() + await vi.waitFor(() => { expect(view.input.value).toBe('') }) + }) + + it('skips without saving and starts a fresh renderer at the entry again', async () => { + const view = mount() + const skipped = Promise.withResolvers() + view.api.skip.mockReturnValue(skipped.promise) + view.button('#api-key').click() + view.enterKey('sk-not-saved') + view.button('#skip-key').click() + try { + expect(view.button('#save-key').textContent).toBe(view.api.messages.welcomeKeySave) + expect(view.button('#skip-key').textContent).toBe(view.api.messages.welcomeKeyLater) + expect(view.button('#save-key').disabled).toBe(true) + expect(view.button('#skip-key').disabled).toBe(true) + expect(view.button('#back-to-login').disabled).toBe(true) + view.button('#skip-key').click() + view.submit() + expect(view.api.skip).toHaveBeenCalledOnce() + expect(view.api.saveApiKey).not.toHaveBeenCalled() + } finally { + skipped.resolve(undefined) + } + await vi.waitFor(() => { expect(view.input.value).toBe('') }) + expect(view.api.skip).toHaveBeenCalledOnce() + expect(view.api.saveApiKey).not.toHaveBeenCalled() + expect(mount().document.querySelector('#key-form')!.hidden).toBe(true) + }) + + it('returns to the entry without saving and clears the draft and validation error', () => { + const view = mount() + view.button('#api-key').click() + view.enterKey('invalid key') + view.submit() + view.button('#back-to-login').click() + expect(view.document.querySelector('#key-form')!.hidden).toBe(true) + expect(view.document.activeElement).toBe(view.button('#api-key')) + expect(view.input.value).toBe('') + expect(view.api.saveApiKey).not.toHaveBeenCalled() + expect(view.api.skip).not.toHaveBeenCalled() + view.button('#api-key').click() + expect(view.input.value).toBe('') + expect(view.document.querySelector('#key-error')!.hidden).toBe(true) + expect(view.button('#save-key').disabled).toBe(true) + }) + + it('keeps visible copy in the shell dictionaries and denies network access', () => { + expect([...html.matchAll(/>([^<]*\p{L}[^<]*) ({ + quit: vi.fn(), + startHost: vi.fn().mockResolvedValue({ url: 'http://127.0.0.1:3080/?token=test' }), + stopHost: vi.fn<() => Promise>().mockResolvedValue(undefined), + loadWorkspace: vi.fn<(url: string) => Promise>().mockResolvedValue(undefined), + showWorkspace: vi.fn(), + closeWelcome: vi.fn(), + welcomeLocale: undefined as unknown, + preference: 'zh', + handlers: new Map unknown>(), + listeners: new Map void>(), + contents: undefined as unknown, + windowOptions: undefined as unknown, + menu: vi.fn(), + operations: undefined as WelcomeOperations | undefined, +})) + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + name: 'Harness', + requestSingleInstanceLock: () => true, + whenReady: () => Promise.resolve(), + getLocale: () => 'en', + getAppPath: () => '/development-app', + getPreferredSystemLanguages: () => ['en-US'], + on: vi.fn(), + quit: state.quit, + exit: vi.fn(), + }, + BrowserWindow: class { + constructor(options: unknown) { state.windowOptions = options } + private ready: (() => void) | undefined + webContents = { mainFrame: { url: 'http://127.0.0.1:3080/?token=test' }, setWindowOpenHandler: vi.fn(), on: vi.fn(), send: vi.fn(), openDevTools: vi.fn() } + static getAllWindows() { return [] } + once(name: string, callback: () => void) { if (name === 'ready-to-show') this.ready = callback; return this } + on() { return this } + isDestroyed() { return false } + hide = vi.fn() + show = state.showWorkspace + async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() } + }, + net: { fetch: vi.fn() }, + protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, + ipcMain: { + handle: (name: string, callback: (...args: unknown[]) => unknown) => { state.handlers.set(name, callback) }, + on: (name: string, callback: (...args: unknown[]) => void) => { state.listeners.set(name, callback) }, + }, + dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() }, + Menu: { buildFromTemplate: state.menu, setApplicationMenu: vi.fn() }, +})) + +vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: '/profile' }) })) +vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class { + applyRelease = vi.fn(async () => {}) + canRecoverProfile = vi.fn(() => true) +} })) +vi.mock('../src/host-process.ts', () => ({ + DesktopHostProcess: class { + start = state.startHost + stop = state.stopHost + fetch() { return Promise.resolve(Response.json({ hasApiKey: true, writable: true, localePreference: state.preference })) } + }, +})) +vi.mock('../src/welcome-backend.ts', () => ({ + connectDesktopWelcome: async () => ({ + read: async () => ({ hasApiKey: true, writable: true, localePreference: state.preference }), + save: async () => ({ ok: true }), + }), +})) +vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() })) +vi.mock('../src/welcome-window.ts', () => ({ + openWelcomeWindow: (locale: unknown, operations: WelcomeOperations) => { + state.welcomeLocale = locale + state.operations = operations + return Promise.resolve({ once: vi.fn(), close: state.closeWelcome }) + }, +})) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +it('starts the Host for a forced welcome preview and opens the workspace on skip without quitting', async () => { + vi.useFakeTimers() + vi.spyOn(process, 'argv', 'get').mockReturnValue(['electron', 'desktop', '--preview-welcome']) + vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile') + vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node') + vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm') + vi.stubEnv('DSH_DESKTOP_DSH_DIR', '/runtime/dsh') + vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) + vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '0') + await import('../src/main.ts') + await vi.waitFor(() => { expect(state.operations).toBeDefined() }) + expect(state.startHost).toHaveBeenCalledOnce() + expect(state.loadWorkspace).toHaveBeenCalledExactlyOnceWith('dsh-app://shell/startup.html') + expect(state.showWorkspace).not.toHaveBeenCalled() + state.loadWorkspace.mockClear() + expect(state.welcomeLocale).toMatchObject({ id: 'zh-CN' }) + await state.operations!.skip() + expect(state.loadWorkspace).toHaveBeenCalledExactlyOnceWith('http://127.0.0.1:3080/?token=test') + expect(state.showWorkspace).toHaveBeenCalledOnce() + expect(state.windowOptions).toMatchObject({ + ...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 18 }, vibrancy: 'sidebar' } : {}), + webPreferences: { contextIsolation: true, sandbox: true }, + }) + expect(state.closeWelcome).toHaveBeenCalledOnce() + expect(state.quit).not.toHaveBeenCalled() + expect(state.stopHost).not.toHaveBeenCalled() + const contents = state.contents as { mainFrame: { url: string } } + const event = { sender: contents, senderFrame: contents.mainFrame } + const bootstrap = state.handlers.get(DESKTOP_IPC.localeBootstrap)! + expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'zh' }) + state.preference = 'en' + expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'en' }) + const changed = state.listeners.get(DESKTOP_IPC.localeChanged)! + const initialMenus = state.menu.mock.calls.length + changed({ ...event, senderFrame: {} }, 'en') + changed(event, 42) + expect(state.menu).toHaveBeenCalledTimes(initialMenus) + changed(event, 'en') + expect(state.menu).toHaveBeenCalledTimes(initialMenus + 1) + const shell = { senderFrame: { url: 'dsh-app://shell/plugin-manager.html' } } + expect(await state.handlers.get(DESKTOP_IPC.localeGet)!(shell)).toMatchObject({ id: 'en' }) +}) diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts new file mode 100644 index 0000000000..7ff64bd364 --- /dev/null +++ b/apps/desktop/tests/welcome-window.spec.ts @@ -0,0 +1,130 @@ +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveDesktopLocale } from '../src/locale.ts' +import { needsWelcome, WELCOME_IPC } from '../src/welcome-api.ts' + +const electron = vi.hoisted(() => ({ + create: vi.fn<(options: unknown) => ReturnType>(), + root: '/desktop-app', + handlers: new Map Promise>(), +})) +vi.mock('electron', () => ({ + app: { getAppPath: () => electron.root }, + BrowserWindow: vi.fn(function (options: unknown) { return electron.create(options) }), + ipcMain: { + handle: (name: string, handler: (event: unknown, value?: unknown) => Promise) => electron.handlers.set(name, handler), + removeHandler: (name: string) => electron.handlers.delete(name), + }, +})) + +const { openWelcomeWindow, welcomeWindowOptions } = await import('../src/welcome-window.ts') + +function createWindow() { + return { + webContents: { + mainFrame: {}, + setWindowOpenHandler: vi.fn<(handler: () => { action: string }) => void>(), + on: vi.fn<(name: string, handler: (event: { preventDefault(): void }) => void) => void>(), + }, + loadFile: vi.fn<(path: string) => Promise>().mockResolvedValue(undefined), + isDestroyed: vi.fn().mockReturnValue(false), + destroy: vi.fn(), + show: vi.fn(), + once: vi.fn<(name: string, callback: () => void) => void>(), + } +} + +beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() }) + +const operations = { + saveApiKey: () => Promise.resolve({ ok: true as const }), + skip: () => Promise.resolve(), +} + +describe('desktop welcome window', () => { + it.each(['darwin', 'win32', 'linux'] as const)('keeps the %s preview fixed-size and sandboxed', (platform) => { + const options = welcomeWindowOptions(platform, resolveDesktopLocale('zh-CN')) + expect(options).toMatchObject({ + width: 600, height: 700, useContentSize: true, center: true, show: false, + resizable: false, maximizable: false, fullscreenable: false, + webPreferences: { + nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, + additionalArguments: ['--dsh-welcome-locale=zh-CN'], + }, + }) + expect(options.webPreferences?.preload).toMatch(/preload-welcome\.cjs$/u) + if (platform === 'darwin') { + expect(options.vibrancy).toBe('titlebar') + expect(options.visualEffectState).toBe('active') + expect(options.trafficLightPosition).toEqual({ x: 16, y: 21 }) + } else if (platform === 'win32') { + expect(options.backgroundMaterial).toBe('acrylic') + expect(options.titleBarOverlay).toMatchObject({ height: 42 }) + } else { + expect(options.backgroundColor).toBe('#FFFFFF') + expect(options.vibrancy).toBeUndefined() + expect(options.backgroundMaterial).toBeUndefined() + } + }) + + it('waits for its local document and blocks renderer navigation', async () => { + const window = createWindow() + const loaded = Promise.withResolvers() + window.loadFile.mockReturnValue(loaded.promise) + electron.create.mockReturnValue(window) + const opening = openWelcomeWindow(resolveDesktopLocale('en'), operations) + expect(window.show).not.toHaveBeenCalled() + expect(window.loadFile).toHaveBeenCalledWith(join(electron.root, 'renderer', 'welcome.html')) + expect(window.webContents.setWindowOpenHandler.mock.calls[0]![0]()).toEqual({ action: 'deny' }) + const event = { preventDefault: vi.fn() } + window.webContents.on.mock.calls[0]![1](event) + expect(event.preventDefault).toHaveBeenCalledOnce() + loaded.resolve(undefined) + expect(await opening).toBe(window) + expect(window.show).toHaveBeenCalledOnce() + }) + + it('destroys a window whose document fails to load', async () => { + const window = createWindow() + window.loadFile.mockRejectedValue(new Error('missing welcome document')) + electron.create.mockReturnValue(window) + await expect(openWelcomeWindow(resolveDesktopLocale('en'), operations)).rejects.toThrow('missing welcome document') + expect(window.destroy).toHaveBeenCalledOnce() + expect(window.show).not.toHaveBeenCalled() + }) + + it('does not show a window closed while its document was loading', async () => { + const window = createWindow() + window.isDestroyed.mockReturnValue(true) + electron.create.mockReturnValue(window) + await openWelcomeWindow(resolveDesktopLocale('en'), operations) + expect(window.show).not.toHaveBeenCalled() + expect(window.destroy).not.toHaveBeenCalled() + }) + + it('accepts actions only from its own top frame and removes handlers on close', async () => { + const window = createWindow() + electron.create.mockReturnValue(window) + const saveApiKey = vi.fn(operations.saveApiKey) + const skip = vi.fn(operations.skip) + await openWelcomeWindow(resolveDesktopLocale('en'), { saveApiKey, skip }) + const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const save = electron.handlers.get(WELCOME_IPC.saveApiKey)! + await expect(save({ sender: {}, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') + await expect(save({ ...own, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') + expect(await save(own, 'bad key')).toEqual({ ok: false }) + expect(saveApiKey).not.toHaveBeenCalled() + expect(await save(own, 'sk-test')).toEqual({ ok: true }) + await electron.handlers.get(WELCOME_IPC.skip)!(own) + expect(skip).toHaveBeenCalledOnce() + window.once.mock.calls[0]![1]() + expect(electron.handlers.size).toBe(0) + }) + + it('shows the entry after logout only without a separately configured API key', () => { + expect(needsWelcome({ loggedIn: true, hasApiKey: false })).toBe(false) + expect(needsWelcome({ loggedIn: false, hasApiKey: false })).toBe(true) + expect(needsWelcome({ loggedIn: false, hasApiKey: true })).toBe(false) + expect(needsWelcome({ loggedIn: true, hasApiKey: true })).toBe(false) + }) +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index aed896b427..88daa327ec 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,11 +12,12 @@ export default defineConfig([ clean: false, deps: { neverBundle: ['electron'] }, }, - ...(['preload-app', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({ + ...(['preload-app', 'preload-welcome', 'preload-platform-account', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. entry: { [name]: `lib/types/${name}.js` }, outDir: 'lib', - format: ['cjs'] as const, + format: 'cjs' as const, + codeSplitting: false, platform: 'node' as const, target: 'es2024', fixedExtension: false, diff --git a/apps/web/tests/desktop-locale.e2e.ts b/apps/web/tests/desktop-locale.e2e.ts new file mode 100644 index 0000000000..f3486111a0 --- /dev/null +++ b/apps/web/tests/desktop-locale.e2e.ts @@ -0,0 +1,83 @@ +// Native initialization supplies system languages; the existing Host preference remains the only durable choice. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/desktop-locale', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: native and Client locale preferences', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const reported: string[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold() + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: 'en-US' }) + tripwire = watchConsole(page) + await page.exposeFunction('nativeLocaleRead', () => { + const section = scaffold.ctx.settings.get('locale') as { preference?: string } + return { languages: ['ja-JP', 'zh-CN', 'en-US'], preference: section.preference ?? null } + }) + await page.exposeFunction('nativeLocaleChanged', (locale: string) => { reported.push(locale) }) + await page.addInitScript(() => { + const bridge = globalThis as typeof globalThis & { + nativeLocaleRead(): Promise + nativeLocaleChanged(locale: string): Promise + __DSH_LOCALE__: { read(): Promise; onChange(locale: string): void } + } + bridge.__DSH_LOCALE__ = { + read: () => bridge.nativeLocaleRead(), + onChange: (locale) => { void bridge.nativeLocaleChanged(locale) }, + } + }) + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('uses OS languages without saving them, then shares an explicit choice across reloads', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-desktop-locale')) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: '设置', exact: true }).click() + const zhDialog = page.getByRole('dialog', { name: '设置', exact: true }) + await zhDialog.getByRole('button', { name: '中文', exact: true }).waitFor() + await expect.poll(() => reported).toEqual(['zh']) + expect(scaffold.ctx.settings.get('locale')).toEqual({}) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'automatic.expected.md'), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE) + await zhDialog.getByRole('button', { name: '中文', exact: true }).click() + await page.getByRole('menuitem', { name: 'English', exact: true }).click() + const enDialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + await enDialog.getByRole('button', { name: 'English', exact: true }).waitFor() + await expect.poll(() => reported.at(-1)).toBe('en') + await expect.poll(() => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')) + .toMatch(/locale:\n\s+preference: en/u) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'selected.expected.md'), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE) + const warningsBefore = tripwire.warnings.length + reported.length = 0 + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await enDialog.getByRole('button', { name: 'English', exact: true }).waitFor() + await expect.poll(() => reported).toEqual(['en']) + expect(await page.evaluate(() => document.documentElement.lang)).toBe('en') + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['automatic.expected.md', 'selected.expected.md']) + }) +}) diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md new file mode 100644 index 0000000000..f80893bfb2 --- /dev/null +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -0,0 +1,50 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "插件": + - img + - text: 插件 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - text: 权限 选择新会话的默认权限模式 + - button "工作区内修改": + - text: 工作区内修改 + - img + - text: 语言 + - button "中文": + - text: 中文 + - img + - text: 外观 + - button "浅色": + - img + - text: 浅色 + - button "深色": + - img + - text: 深色 + - button "跟随系统" [pressed]: + - img + - text: 跟随系统 + - text: 字号大小 仅影响会话内容的字号 14 + - button "增大字号": + - img + - button "减小字号": + - img + - text: px 对话显示 控制已完成轮次的过程内容 + - button "紧凑": + - text: 紧凑 + - img + - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 + - button "排队发送": + - text: 排队发送 + - img diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md new file mode 100644 index 0000000000..3121238a25 --- /dev/null +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -0,0 +1,50 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General": + - img + - text: General + - button "Models": + - img + - text: Models + - button "Plugins": + - img + - text: Plugins + - button "Agent presets": + - img + - text: Agent presets + - button "Open configuration file" + - button "Close": + - img + - text: Close + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write": + - text: Workspace Write + - img + - text: Language + - button "English": + - text: English + - img + - text: Appearance + - button "Light": + - img + - text: Light + - button "Dark": + - img + - text: Dark + - button "System" [pressed]: + - img + - text: System + - text: Font size Only affects conversation content 14 + - button "Increase font size": + - img + - button "Decrease font size": + - img + - text: px Conversation display Controls process content in completed turns + - button "Compact": + - text: Compact + - img + - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior + - button "Queue": + - text: Queue + - img diff --git a/apps/web/tests/expected/onboarding-native/models.expected.md b/apps/web/tests/expected/onboarding-native/models.expected.md new file mode 100644 index 0000000000..74bf74bb3a --- /dev/null +++ b/apps/web/tests/expected/onboarding-native/models.expected.md @@ -0,0 +1,35 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "插件": + - img + - text: 插件 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek deepseek-official API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥 + - group: 自定义设置 + - button "取消" + - button "保存" + - button "添加提供方": + - img + - text: 添加提供方 + - button "添加自定义提供方": + - img + - text: 添加自定义提供方 diff --git a/apps/web/tests/fixtures/onboarding-native/cordis.patch.yml b/apps/web/tests/fixtures/onboarding-native/cordis.patch.yml new file mode 100644 index 0000000000..3fa7f00794 --- /dev/null +++ b/apps/web/tests/fixtures/onboarding-native/cordis.patch.yml @@ -0,0 +1,3 @@ +- id: ui-settings-models + config: + credentialOnboarding: false diff --git a/apps/web/tests/onboarding-native.e2e.ts b/apps/web/tests/onboarding-native.e2e.ts new file mode 100644 index 0000000000..0b7e269ac3 --- /dev/null +++ b/apps/web/tests/onboarding-native.e2e.ts @@ -0,0 +1,70 @@ +// The Host's native-onboarding setting must reach the assembled browser before its dialogs register. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, WELCOME_NOTICE_COPY, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-native', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credential onboarding (desktop marker: %s)', (desktop) => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + deepSeekMissingCredential: true, + welcomeNoticePending: true, + ...desktop ? {} : { extraOverlayPath: fileURLToPath(new URL('./fixtures/onboarding-native/cordis.patch.yml', import.meta.url)) }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + if (desktop) await page.addInitScript(() => { Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } }) }) + tripwire = watchConsole(page) + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('keeps the notice and Models settings without another credential dialog or credential write', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-native')) + const credentialPath = join(scaffold.harnessHome, '.credentials.yaml') + const credentials = await readFile(credentialPath, 'utf8') + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) + await welcome.waitFor() + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached' }) + + for (const reload of [false, true]) { + if (reload) { + const warningsBefore = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + } + await page.getByRole('button', { name: '设置', exact: true }).click() + const settings = page.getByRole('dialog', { name: '设置', exact: true }) + await settings.getByRole('button', { name: '模型', exact: true }).click() + await settings.getByLabel('API 密钥', { exact: true }).waitFor() + expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0) + expect(await welcome.count()).toBe(0) + expect(await readFile(credentialPath, 'utf8')).toBe(credentials) + const aria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'models.expected.md'), aria, MODE) + await page.keyboard.press('Escape') + } + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['models.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 12cc32a904..237608f4d4 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -54,6 +54,7 @@ "tests/plugin-install-approve.e2e.ts", "tests/client-plugin-live.e2e.ts", "tests/settings-chrome.e2e.ts", + "tests/desktop-locale.e2e.ts", "tests/desktop-updates.e2e.ts", "tests/developer-tools-settings.e2e.ts", "tests/models-settings.e2e.ts", @@ -65,6 +66,7 @@ "tests/streaming-fence-highlight.e2e.ts", "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", + "tests/onboarding-native.e2e.ts", "tests/onboarding-usable-provider.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-new-session-folding.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9abb7e2f19..7acbc376d5 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: fd7ddf0b0ea3eef331283531a860a2079d02cf81 -config-catalog.zh.md: 7f58a328ec0650ee3032950d7afadd8a7fdd6a5b +config-catalog.md: 99ef3f4080f5e23ccfa9ebea0d4b89daf443bcfb +config-catalog.zh.md: 0e2cb6eb3f6936b7ad190333a83a701ecbbb133d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fd7ddf0b0e..99ef3f4080 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -446,6 +446,20 @@ export interface Config { Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-ui-settings-models` + +```ts config-catalog +/** Onboarding options after schema defaults are applied. */ +export interface Config { + /** Offer the browser API-key step when no native shell owns credential onboarding. */ + credentialOnboarding: boolean +} +``` + +Source: [`packages/client/ui-settings-models/src/onboarding-config.ts:6`](../packages/client/ui-settings-models/src/onboarding-config.ts) + ## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` @@ -3807,7 +3821,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-session` ([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7f58a328ec..0e2cb6eb3f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -448,6 +448,20 @@ export interface Config { 来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-ui-settings-models` + +```ts config-catalog +/** Onboarding options after schema defaults are applied. */ +export interface Config { + /** Offer the browser API-key step when no native shell owns credential onboarding. */ + credentialOnboarding: boolean +} +``` + +来源: [`packages/client/ui-settings-models/src/onboarding-config.ts:6`](../packages/client/ui-settings-models/src/onboarding-config.ts) + ## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` @@ -3809,7 +3823,6 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-session`([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 14b3339b26..75d42010f7 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/locale/README.md -README.md: 56a9cff9c3ec18dcc395378fcd1691207c022284 -README.zh.md: 1b7a7b7de48843961f3cbac3f4ee99cff641d212 +README.md: 39cff9dbeb29d32e2da611512e0849c007d96853 +README.zh.md: 4f4cdacfb7a5b0a65fb0065e0186ee765488a17e diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 56a9cff9c3..39cff9dbeb 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -31,6 +31,8 @@ Use it wherever the web GUI needs a language switch or translated copy: the ship Open Settings → General and select a registered language. The active locale is applied immediately: the UI copy switches, `` points at the external id or built-in document tag, and the choice is written to the durable settings section. A browser without an explicit Host preference selects the first registered language that matches `navigator` by full tag and then primary subtag, falling back to English. A stored external locale waits for its definition to register instead of becoming active while unavailable. +Native shells may provide `__DSH_LOCALE__` with an asynchronous `read()` and an `onChange(locale)` callback. Initialization supplies the current Host preference and ordered OS languages before the Client mounts. Automatic selection stays provisional; only Settings selections write `locale.preference`. A fresh read on each page load prevents a stale preload preference after reload. Ordinary browsers keep navigator-based detection and their existing settings-scope policy. + ### Registering a dictionary Call `ctx.locale.register(ns, { zh, en })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both shipped locales. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 1b7a7b7de4..4f4cdacfb7 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 打开“设置 → 常规”并选择一种已注册语言。生效中的 locale 会立即应用:UI 文案切换、`` 指向外部 id 或内置语言的文档标签,选择写入持久设置分区。没有显式 Host 偏好的浏览器会按完整标签、再按主语言子标签选择 `navigator` 请求的第一个已注册语言,无法匹配时回退到英文。已存储的外部 locale 会等待其定义注册,不会在不可用时生效。 +原生壳可以提供包含异步 `read()` 和 `onChange(locale)` 回调的 `__DSH_LOCALE__`。初始化在 Client 挂载前提供当前 Host 偏好和有序的系统语言列表。自动选择保持临时状态;只有设置中的选择会写入 `locale.preference`。每次加载页面都重新读取,避免重载后沿用过期的 preload 偏好。普通浏览器继续使用 navigator 检测和原有的设置作用域策略。 + ### 注册字典 用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个内置 locale 齐全。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。 diff --git a/packages/client/locale/src/client/bootstrap.ts b/packages/client/locale/src/client/bootstrap.ts new file mode 100644 index 0000000000..34a89b2716 --- /dev/null +++ b/packages/client/locale/src/client/bootstrap.ts @@ -0,0 +1,31 @@ +/** Locale initialization supplied by a native shell before its Client tree mounts. */ + +/** System languages and the existing Host preference for one page load. */ +export interface LocaleBootstrap { + /** Operating-system language tags in preference order. */ + readonly languages: readonly string[] + /** Stored locale.preference; null preserves automatic language selection. */ + readonly preference: string | null +} + +/** Optional page-global bridge named __DSH_LOCALE__; ordinary browsers omit it. */ +export interface LocaleBridge { + /** Read current initialization data through the shell's isolated preload. @returns unvalidated IPC data. */ + read(): Promise + /** Publish the resolved Client locale without writing another preference. @param locale - active registered locale id. */ + onChange(locale: string): void +} + +/** + * Validate initialization data returned over the preload IPC bridge. + * @param value - untrusted IPC response. + * @returns language initialization with no persistence side effects. + */ +export function parseLocaleBootstrap(value: unknown): LocaleBootstrap { + if (typeof value !== 'object' || value === null || !('languages' in value) || !Array.isArray(value.languages) + || !value.languages.every((language: unknown) => typeof language === 'string') + || !('preference' in value) || (value.preference !== null && typeof value.preference !== 'string')) { + throw new TypeError('locale: invalid native initialization data') + } + return { languages: value.languages, preference: value.preference } +} diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 8370696886..7523455af5 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -12,6 +12,7 @@ import { // Cross-plugin collaboration goes through the service, never a value import // (client bundle purity gate). import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' +import { parseLocaleBootstrap, type LocaleBootstrap, type LocaleBridge } from './bootstrap.ts' // Type-only: pulls the SlotRegistry service merge (ctx.slots). import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { @@ -178,14 +179,16 @@ export class LocaleRuntime { * listener is released through ctx.effect on dispose). * @param host - durable preference scope owned by the providing plugin; * absent compositions (standalone dictionary registries) stay process-local. + * @param bootstrap - native initialization; absent in ordinary browsers. */ - constructor(ctx: ClientContext, host?: SettingsScope) { + constructor(ctx: ClientContext, host?: SettingsScope, private readonly bootstrap?: LocaleBootstrap) { this.ctx = ctx this.host = host for (const locale of BUILT_IN_LOCALES) this.catalog.set(localeKey(locale.id), locale) const locales = this.localeList() - this.provisional = resolveInitialLocale(locales) - this.snapshot = Object.freeze({ active: this.provisional, locales, revision: 0 }) + this.provisional = resolveInitialLocale(locales, bootstrap?.languages) + this.preference = bootstrap?.preference ?? undefined + this.snapshot = Object.freeze({ active: this.resolveActive(), locales, revision: 0 }) if (host !== undefined) { ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption') this.adopt(host) @@ -295,7 +298,7 @@ export class LocaleRuntime { private publishCatalog(): void { this.fallbackChains.clear() const locales = this.localeList() - this.provisional = resolveInitialLocale(locales) + this.provisional = resolveInitialLocale(locales, this.bootstrap?.languages) const active = this.resolveActive() this.publish(active, active !== this.snapshot.active, locales) } @@ -497,8 +500,8 @@ export class LocaleRuntime { * The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit * Host preference may replace this provisional value after plugin activation. */ -function resolveInitialLocale(locales: readonly LocaleDefinition[]): LocaleId { - return detectBrowserLocale(locales) ?? FALLBACK_LOCALE +function resolveInitialLocale(locales: readonly LocaleDefinition[], languages?: readonly string[]): LocaleId { + return detectBrowserLocale(locales, languages) ?? FALLBACK_LOCALE } /** @@ -510,13 +513,17 @@ function resolveInitialLocale(locales: readonly LocaleDefinition[]): LocaleId { * locale for non-browser runs. `navigator.language` trails the ordered * `languages` list and covers hosts exposing only the single tag. * @param locales - definitions currently available to the browser. + * @param languages - native system language order, when supplied by a shell. * @returns the first matching locale id, or undefined. */ -function detectBrowserLocale(locales: readonly LocaleDefinition[]): LocaleId | undefined { - if (typeof window === 'undefined') return undefined - // Embedders and older WebViews may omit the DOM-typed `languages` property. - const languages = (navigator as { readonly languages?: readonly string[] }).languages - for (const tag of [...(languages ?? []), navigator.language]) { +function detectBrowserLocale(locales: readonly LocaleDefinition[], languages?: readonly string[]): LocaleId | undefined { + if (languages === undefined) { + if (typeof window === 'undefined') return undefined + // Embedders and older WebViews may omit the DOM-typed `languages` property. + const browserLanguages = (navigator as { readonly languages?: readonly string[] }).languages + languages = [...(browserLanguages ?? []), navigator.language] + } + for (const tag of languages) { const requested = localeKey(tag) const exact = locales.find(locale => localeKey(locale.id) === requested) if (exact !== undefined) return exact.id @@ -535,13 +542,31 @@ export const inject = ['slots', 'remote', 'settingsScope'] * register the feature-owned Language preference row into the General * section's item slot (a feature owns its settings surface). * @param ctx - client cordis context. + * @returns resolves after native language initialization and plugin registration. */ -export function apply(ctx: ClientContext): void { +export async function apply(ctx: ClientContext): Promise { + const bridge = (globalThis as { __DSH_LOCALE__?: LocaleBridge }).__DSH_LOCALE__ + let bootstrap: LocaleBootstrap | undefined + if (bridge !== undefined) { + let value: unknown + try { + value = await bridge.read() + } catch (error) { + if (ctx.fiber.uid === null) return + throw error + } + if (ctx.fiber.uid === null) return + bootstrap = parseLocaleBootstrap(value) + } const host = ctx.settingsScope.bind({ namespace: LOCALE_SETTINGS_NAMESPACE }) - const locale = new LocaleRuntime(ctx, host) + const locale = new LocaleRuntime(ctx, host, bootstrap) locale.register(COMMON_NS, { zh, en }) locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) ctx.provide('locale', locale) + if (bridge !== undefined) { + ctx.on('locale/change', (snapshot) => { bridge.onChange(snapshot.active) }) + bridge.onChange(locale.getSnapshot().active) + } // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it // so the render machinery can synthesize the `t` standard seat. ctx.slots.installLocale(locale) diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index c4a5b11284..a373ecefc2 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' @@ -15,11 +15,11 @@ import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' const SLOT = 'settings.general.item' +afterEach(() => { vi.unstubAllGlobals() }) -async function bench() { +async function bench(preference?: string) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() - let preference: string | undefined let revision = 0 const namespace = () => ({ ns: LOCALE_SETTINGS_NAMESPACE, @@ -65,6 +65,52 @@ function faceOf(slots: SlotRegistry) { } describe('locale apply', () => { + it.each(['resolve', 'reject'] as const)('contains a late native initialization %s after unloading', async (outcome) => { + const b = await bench() + const ready = Promise.withResolvers() + const read = vi.fn(() => ready.promise) + const onChange = vi.fn() + vi.stubGlobal('__DSH_LOCALE__', { read, onChange }) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + try { + await vi.waitFor(() => { expect(read).toHaveBeenCalledOnce() }) + const disposal = fiber.dispose() + if (outcome === 'resolve') ready.resolve({ languages: ['zh-CN'], preference: null }) + else ready.reject(new Error('native window closed')) + await disposal + expect(b.ctx.get('locale')).toBeUndefined() + expect(onChange).not.toHaveBeenCalled() + } finally { + ready.resolve({ languages: ['zh-CN'], preference: null }) + await b.ctx.fiber.dispose() + } + }) + + it('awaits native initialization and reports later changes only while mounted', async () => { + const b = await bench('zh') + const ready = Promise.withResolvers() + const onChange = vi.fn() + vi.stubGlobal('__DSH_LOCALE__', { read: () => ready.promise, onChange }) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + try { + expect(b.ctx.get('locale')).toBeUndefined() + ready.resolve({ languages: ['en-US'], preference: 'zh' }) + await fiber.await() + const locale = b.ctx.get('locale') as LocaleRuntime + expect(locale.getSnapshot().active).toBe('zh') + expect(onChange).toHaveBeenLastCalledWith('zh') + locale.setLocale('en') + expect(onChange).toHaveBeenLastCalledWith('en') + await fiber.dispose() + const calls = onChange.mock.calls.length + b.ctx.emit('locale/change', { active: 'zh', locales: [], revision: 99 }) + expect(onChange).toHaveBeenCalledTimes(calls) + } finally { + ready.resolve({ languages: ['en-US'], preference: 'zh' }) + await b.ctx.fiber.dispose() + } + }) + // These are wiring specs, not default-language specs. A fresh LocaleRuntime // with no jsdom `window` skips browser detection and opens on FALLBACK_LOCALE // (en); each test that reads localized copy stages its locale explicitly via diff --git a/packages/client/locale/tests/bootstrap.client.spec.ts b/packages/client/locale/tests/bootstrap.client.spec.ts new file mode 100644 index 0000000000..ea3966d90c --- /dev/null +++ b/packages/client/locale/tests/bootstrap.client.spec.ts @@ -0,0 +1,75 @@ +/** Native initialization precedes Client mounting and does not persist automatic locale choices. */ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, LocaleRuntime } from '../src/client/index.ts' +import { parseLocaleBootstrap } from '../src/client/bootstrap.ts' +import type { LocaleSettings } from '../src/locale-settings.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +describe('native locale initialization', () => { + it('reports a failed native read instead of silently choosing a language', async () => { + const ctx = new Context() + const error = new Error('native settings unavailable') + vi.stubGlobal('__DSH_LOCALE__', { read: () => Promise.reject(error), onChange: vi.fn() }) + try { + await expect(apply(ctx)).rejects.toBe(error) + expect(ctx.get('locale')).toBeUndefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('uses native language order without persisting a provisional choice', async () => { + vi.stubGlobal('window', {}) + vi.stubGlobal('navigator', { languages: ['en-US'], language: 'en-US' }) + const ctx = new Context() + const host = stubSettingsScope() + try { + const locale = new LocaleRuntime(ctx, host.scope, { languages: ['ja-JP', 'zh-Hant', 'en-US'], preference: null }) + expect(locale.getSnapshot().active).toBe('zh') + expect(host.set).not.toHaveBeenCalled() + locale.setLocale('en') + expect(host.set).toHaveBeenCalledExactlyOnceWith('preference', 'en') + } finally { + await ctx.fiber.dispose() + } + }) + + it('keeps the stored choice while dictionaries and languages register', async () => { + const ctx = new Context() + try { + const locale = new LocaleRuntime(ctx, undefined, { languages: ['zh-CN'], preference: 'en' }) + expect(locale.getSnapshot().active).toBe('en') + locale.register('native-test', 'en', {}) + expect(locale.getSnapshot().active).toBe('en') + locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + expect(locale.getSnapshot().active).toBe('en') + } finally { + await ctx.fiber.dispose() + } + }) + + it('keeps an external preference pending until its language registers', async () => { + const ctx = new Context() + try { + const locale = new LocaleRuntime(ctx, undefined, { languages: ['zh-CN'], preference: 'ja' }) + expect(locale.getSnapshot().active).toBe('zh') + const remove = locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + expect(locale.getSnapshot().active).toBe('ja') + remove() + expect(locale.getSnapshot().active).toBe('zh') + } finally { + await ctx.fiber.dispose() + } + }) + + it('validates the preload response before reading its fields', () => { + expect(parseLocaleBootstrap({ languages: ['en-US'], preference: null })).toEqual({ languages: ['en-US'], preference: null }) + expect(parseLocaleBootstrap({ languages: [], preference: 'zh' }).preference).toBe('zh') + for (const value of [null, {}, { languages: 'en', preference: null }, { languages: [42], preference: null }, { languages: [], preference: 42 }]) { + expect(() => parseLocaleBootstrap(value)).toThrow('invalid native initialization data') + } + }) +}) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c3c33c6f90..0365518cc1 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: c1567814b5de39f9b8819fcc8848e36c0a6ff842 -README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27 +README.md: d89bc11c45ecce69f70f685c657c662faebd2382 +README.zh.md: 61e00aed9b54713469ed4f5c5395e125ab2a9f18 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index c1567814b5..d89bc11c45 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -29,6 +29,8 @@ Open the Models page from the Settings navigation to see every configured provid A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic. +Host configuration `credentialOnboarding` defaults to `true`. Electron’s preload marker suppresses the credential step automatically; other native shells can set it to `false` in the plugin row; the Models settings page and welcome notice remain available. The Host publishes this public boolean through `webserver/index-inject`, and the Client validates it before registering its dialogs. It is page initialization data, not a persisted completion flag. + ### API keys The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index baa0f02e82..61e00aed9b 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" 存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 +Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会自动抑制凭证步骤;其他原生壳可以在插件行中把它设为 `false`;模型设置页和欢迎须知仍然可用。Host 通过 `webserver/index-inject` 发布这个公开的布尔值,Client 在注册弹窗前校验它。它是页面初始化数据,不是持久化的完成标记。 + ### API 密钥 编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 74cc7e2132..fe1dcc15b1 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -44,7 +44,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-remote-mock": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", @@ -56,7 +56,11 @@ "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 721f807092..5c45ca70f9 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -27,6 +27,7 @@ import { createModelsOperations } from './operations.ts' import { createSettingsSchemaOperations } from './schema-operations.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' +import { Config, ONBOARDING_CONFIG_GLOBAL } from '../onboarding-config.ts' export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx' export type { ModelsFooterOwnerProps, ProviderCardExtrasOwnerProps } from './slot-contract.ts' @@ -41,6 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'settings.models' + export type { ModelsSettingsState, ProviderDirectoryEntry, ProviderRow, } from './store.ts' @@ -73,6 +75,10 @@ export const inject = [ * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + const page = globalThis as Partial> + const payload = page[ONBOARDING_CONFIG_GLOBAL] + const configured = Config(payload === undefined ? {} : payload) + const credentialOnboarding = configured.credentialOnboarding && !('dshDesktop' in globalThis) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const schema = createSettingsSchemaOperations(ctx.settingsSchema) @@ -145,10 +151,12 @@ export function apply(ctx: ClientContext): void { order: -100, inject: welcomeInjected, }, WelcomeNotice)) - ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ - name: 'settings.onboarding', - id: 'deepseek-official', - order: 0, - inject: deepSeekOnboardingInjected, - }, DeepSeekOnboardingDialog)) + if (credentialOnboarding) { + ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ + name: 'settings.onboarding', + id: 'deepseek-official', + order: 0, + inject: deepSeekOnboardingInjected, + }, DeepSeekOnboardingDialog)) + } } diff --git a/packages/client/ui-settings-models/src/index.ts b/packages/client/ui-settings-models/src/index.ts index da3060fe59..d73e39dc5e 100644 --- a/packages/client/ui-settings-models/src/index.ts +++ b/packages/client/ui-settings-models/src/index.ts @@ -1,4 +1,22 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host configuration and page bootstrap for Models credential onboarding. */ -/** Host plugin body — no host-side behavior for the models settings plugin. */ -export function apply(): void {} +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import { type Config, ONBOARDING_CONFIG_GLOBAL } from './onboarding-config.ts' + +export { Config } from './onboarding-config.ts' + +/** + * Publish the credential-onboarding choice before browser plugins activate. + * @param ctx - Host context collecting the page's initialization data. + * @param config - plugin options with schema defaults applied by the Loader. + */ +export function apply(ctx: Context, config: Config): void { + ctx.on('webserver/index-inject', (table) => { + table.push({ + kind: 'global', + name: ONBOARDING_CONFIG_GLOBAL, + value: { credentialOnboarding: config.credentialOnboarding }, + }) + }) +} diff --git a/packages/client/ui-settings-models/src/onboarding-config.ts b/packages/client/ui-settings-models/src/onboarding-config.ts new file mode 100644 index 0000000000..2213ea3a93 --- /dev/null +++ b/packages/client/ui-settings-models/src/onboarding-config.ts @@ -0,0 +1,17 @@ +/** Public page-bootstrap options shared by the Host and Client halves. */ + +import z from '@deepseek-ai/schemastery' + +/** Onboarding options after schema defaults are applied. */ +export interface Config { + /** Offer the browser API-key step when no native shell owns credential onboarding. */ + credentialOnboarding: boolean +} + +/** Validate Host configuration and its public page-bootstrap payload. */ +export const Config: z, Config> = z.object({ + credentialOnboarding: z.boolean().default(true), +}) + +/** Page-global key carrying only the public onboarding options. */ +export const ONBOARDING_CONFIG_GLOBAL = '__DSH_MODELS_ONBOARDING__' diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index da1dd00f50..f079e00151 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -1,6 +1,6 @@ /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -15,7 +15,11 @@ import { import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' -import { apply as hostApply } from '../src/index.ts' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import * as hostPlugin from '../src/index.ts' +import { ONBOARDING_CONFIG_GLOBAL } from '../src/onboarding-config.ts' + +afterEach(() => { vi.unstubAllGlobals() }) // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -62,8 +66,41 @@ function declare(slots: SlotRegistry): () => void { } describe('ui-settings-models apply', () => { - it('keeps the host Loader entry inert', () => { - expect(hostApply).not.toThrow() + it('lets a native shell own credential onboarding while retaining settings and the notice', async () => { + const { ctx, slots } = await bench() + declare(slots) + try { + const host = ctx.plugin(hostPlugin, { credentialOnboarding: false }) + await host.await() + const rows: IndexInjection[] = [] + ctx.emit('webserver/index-inject', rows) + expect(rows).toEqual([{ kind: 'global', name: ONBOARDING_CONFIG_GLOBAL, value: { credentialOnboarding: false } }]) + for (const row of rows) if (row.kind === 'global') vi.stubGlobal(row.name, row.value) + const plugin = ctx.plugin({ inject: [...inject], apply }) + await plugin.await() + expect(slots.entries('settings.onboarding').map(entry => entry.options.id)).toEqual(['welcome-notice']) + expect(slots.entries('settings.section').map(entry => entry.options.id)).toEqual(['models']) + await plugin.dispose() + expect(slots.entries('settings.onboarding')).toEqual([]) + await host.dispose() + const after: IndexInjection[] = [] + ctx.emit('webserver/index-inject', after) + expect(after).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + + it('defaults to browser onboarding and rejects malformed bootstrap options', async () => { + expect(hostPlugin.Config({})).toEqual({ credentialOnboarding: true }) + expect(hostPlugin.Config['~standard'].validate({ credentialOnboarding: 'false' })).toHaveProperty('issues') + const { ctx } = await bench() + try { + vi.stubGlobal(ONBOARDING_CONFIG_GLOBAL, { credentialOnboarding: 'false' }) + expect(() => { apply(ctx) }).toThrow() + } finally { + await ctx.fiber.dispose() + } }) it('declares the services it uses', () => { diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index 3c276ddd5f..27e35f4500 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -8,6 +8,8 @@ "src" ], "references": [ + { "path": "../../host/webserver" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index a7e0830c66..ef6ca6adde 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -39,7 +39,7 @@ import { } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createConversationStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import * as localePlugin from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -293,7 +293,7 @@ async function bench(snapshot = historySnapshot(NODES)) { // inject); its settings scope needs a connection handle. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) - await runtime.mount({ inject: [...localeInject], apply: localeApply }) + await runtime.mount(localePlugin) const provide = vi.spyOn(ctx.uiSession, 'provide') const feature = await runtime.mount({ inject: [...inject], apply }) const sourceDescriptor = provide.mock.calls[0]?.[0] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d28edf0ab4..d4374d8a7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4110,6 +4110,10 @@ importers: version: 18.3.1 packages/client/ui-settings-models: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4138,6 +4142,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver '@deepseek-ai/dsh-remote-mock': specifier: workspace:^ version: link:../../test-support/remote-mock diff --git a/tsconfig.host.json b/tsconfig.host.json index 82b9fde2c3..aafd4ece8f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -51,11 +51,14 @@ "apps/web/tests/plugin-install-approve.e2e.ts", "apps/web/tests/client-plugin-live.e2e.ts", "apps/web/tests/settings-chrome.e2e.ts", + "apps/web/tests/desktop-locale.e2e.ts", + "apps/web/tests/window-controls.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/deepseek-messages-settings.e2e.ts", "apps/web/tests/deepseek-messages-chat.e2e.ts", "apps/web/tests/models-settings-recovery.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/onboarding-native.e2e.ts", "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-new-session-folding.e2e.ts", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index de04f77bed..882357bb69 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -40,9 +40,9 @@ export default defineConfig({ test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], - // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built + // Explicit Node app owners only: apps/web/tests/*.e2e.ts needs the built // frontend dist and runs under vitest.web.config.ts (the test:web job). - include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts'], + include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts', 'apps/desktop/tests/**/*.e2e.ts'], exclude: [ '**/*.expected.e2e.ts', 'packages/experimental/inspector/tests/client-browser.e2e.ts', From 048297321ad1364557308d360b8e535978ec8876 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:06:16 +0800 Subject: [PATCH 007/121] feat(desktop,credentials,client): integrate DeepSeek account sign-in and Platform pages --- ...026-09-14-deepseek-account-login.i18n.yaml | 6 + .../2026-09-14-deepseek-account-login.md | 55 ++ .../2026-09-14-deepseek-account-login.zh.md | 55 ++ THIRD_PARTY_NOTICES.md | 2 + apps/cli/composition.md | 6 + apps/desktop-host/package.json | 3 +- apps/desktop-host/src/index.ts | 21 + apps/desktop-host/tsconfig.json | 3 + apps/desktop/electron-builder.config.d.mts | 1 + apps/desktop/package.json | 8 +- apps/desktop/renderer/welcome.css | 6 + apps/desktop/renderer/welcome.html | 12 +- apps/desktop/renderer/welcome.js | 85 ++- apps/desktop/scripts/dev.ts | 2 +- apps/desktop/src/account-backend.ts | 112 ++++ apps/desktop/src/host-process.ts | 31 +- apps/desktop/src/platform-ipc.ts | 8 + apps/desktop/src/platform-view.ts | 130 +++++ apps/desktop/src/preload-platform-account.ts | 24 + apps/desktop/src/preload-welcome.ts | 9 + apps/desktop/src/welcome-api.ts | 19 +- apps/desktop/src/welcome-backend.ts | 7 + apps/desktop/src/welcome-window.ts | 17 +- apps/desktop/tests/account-backend.spec.ts | 26 + .../expected/welcome/en-timeout.expected.txt | 6 + .../tests/expected/welcome/en.expected.txt | 2 +- .../welcome/zh-CN-timeout.expected.txt | 6 + .../tests/expected/welcome/zh-CN.expected.txt | 2 +- apps/desktop/tests/host-process.spec.ts | 11 + apps/desktop/tests/macos-signature.spec.ts | 1 + apps/desktop/tests/platform-preload.e2e.ts | 50 ++ apps/desktop/tests/platform-view.spec.ts | 106 ++++ apps/desktop/tests/welcome-flow.e2e.ts | 84 ++- apps/desktop/tests/welcome-host.spec.ts | 5 +- apps/desktop/tests/welcome-renderer.spec.ts | 18 + apps/desktop/tests/welcome-startup.spec.ts | 13 +- apps/desktop/tests/welcome-window.spec.ts | 7 +- apps/desktop/tsconfig.json | 18 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 9 + docs/capability-seams.zh.md | 9 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 48 ++ docs/config-catalog.zh.md | 48 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/sign-in-readiness.i18n.yaml | 6 + docs/sign-in-readiness.md | 39 ++ docs/sign-in-readiness.zh.md | 39 ++ docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 76 +++ docs/subsystems/credentials.zh.md | 76 +++ .../api/account-controller/README.i18n.yaml | 6 + packages/api/account-controller/README.md | 53 ++ packages/api/account-controller/README.zh.md | 53 ++ packages/api/account-controller/package.json | 56 ++ packages/api/account-controller/src/index.ts | 61 ++ packages/api/account-controller/src/types.ts | 2 + packages/api/account-controller/tsconfig.json | 24 + packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 + packages/api/gateway/README.zh.md | 2 + packages/api/gateway/package.json | 6 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 + packages/api/remotes/README.zh.md | 2 + packages/api/remotes/package.json | 3 +- packages/api/remotes/src/client/index.ts | 4 +- packages/api/remotes/tsconfig.client.json | 3 + packages/api/remotes/tsconfig.host.json | 3 + packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 + packages/bundle/base/README.zh.md | 2 + packages/bundle/base/cordis.patch.yml | 6 + packages/bundle/base/package.json | 4 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 + packages/bundle/web-app/README.zh.md | 2 + packages/bundle/web-app/cordis.patch.yml | 6 + packages/bundle/web-app/package.json | 4 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + packages/client/ui-primitives/src/Menu.tsx | 6 +- .../ui-settings-account/README.i18n.yaml | 6 + packages/client/ui-settings-account/README.md | 61 ++ .../client/ui-settings-account/README.zh.md | 61 ++ .../client/ui-settings-account/package.json | 74 +++ .../src/client/AccountMenu.module.css | 11 + .../src/client/AccountMenu.tsx | 58 ++ .../src/client/AccountOnboarding.tsx | 17 + .../src/client/AccountSection.module.css | 181 ++++++ .../src/client/AccountSection.tsx | 134 +++++ .../src/client/LogoutIcon.tsx | 10 + .../src/client/PlatformOverlay.module.css | 62 ++ .../src/client/PlatformOverlay.tsx | 70 +++ .../src/client/SignInDialog.module.css | 17 + .../src/client/SignInDialog.tsx | 69 +++ .../src/client/browser-login.ts | 71 +++ .../src/client/contact-url.ts | 34 ++ .../src/client/formatBalance.ts | 21 + .../ui-settings-account/src/client/index.ts | 135 +++++ .../ui-settings-account/src/client/locales.ts | 46 ++ .../ui-settings-account/src/contact-config.ts | 17 + .../ui-settings-account/src/css-modules.d.ts | 6 + .../client/ui-settings-account/src/index.ts | 16 + .../tests/account.client.spec.tsx | 137 +++++ .../tests/browser-login.client.spec.ts | 71 +++ .../tests/contact-url.client.spec.ts | 31 + .../tests/expected/account-en.txt | 1 + .../tests/expected/account-signed-out-en.txt | 1 + .../tests/expected/account-signed-out-zh.txt | 1 + .../tests/expected/account-zh.txt | 1 + .../tests/expected/details-en.txt | 1 + .../tests/expected/details-zh.txt | 1 + .../tests/expected/login-en.txt | 1 + .../tests/expected/login-timeout-en.txt | 1 + .../tests/expected/login-timeout-zh.txt | 1 + .../tests/expected/login-waiting-en.txt | 1 + .../tests/expected/login-waiting-zh.txt | 1 + .../tests/expected/login-zh.txt | 1 + .../tests/expected/menu-en.txt | 1 + .../tests/expected/menu-zh.txt | 1 + .../tests/expected/platform-header-en.txt | 1 + .../tests/expected/platform-header-zh.txt | 1 + .../tests/expected/profile-before-balance.txt | 1 + .../tests/formatBalance.client.spec.ts | 10 + .../tests/sign-in-dialog.client.spec.tsx | 49 ++ .../client/ui-settings-account/tsconfig.json | 45 ++ .../ui-settings-account/tsdown.config.ts | 3 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 4 + .../client/ui-settings-general/README.zh.md | 4 + .../src/client/SettingsRoot.module.css | 14 +- .../src/client/SettingsRoot.tsx | 25 +- .../ui-settings-general/src/client/index.ts | 1 + .../src/client/shell-contract.ts | 1 + .../tests/settings-root.client.spec.tsx | 15 +- .../tests/shell.client.spec.ts | 3 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../src/client/DeepSeekOnboardingDialog.tsx | 32 +- .../ui-settings-models/src/client/index.ts | 16 +- .../src/client/slot-contract.ts | 2 + .../tests/apply.client.spec.ts | 6 +- .../tests/onboarding-dialog.client.spec.tsx | 31 + packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 + packages/client/ui-settings/README.zh.md | 2 + .../ui-settings/src/client/contract/slots.ts | 15 + .../client/ui-settings/src/client/index.ts | 2 +- .../authorization/README.i18n.yaml | 8 +- packages/credentials/authorization/README.md | 2 + .../credentials/authorization/README.zh.md | 2 + .../credentials/authorization/src/index.ts | 27 +- .../authorization/tests/authorization.spec.ts | 42 ++ .../README.i18n.yaml | 6 + .../deepseek-account-platform/README.md | 80 +++ .../deepseek-account-platform/README.zh.md | 80 +++ .../deepseek-account-platform/package.json | 52 ++ .../deepseek-account-platform/src/details.ts | 51 ++ .../deepseek-account-platform/src/index.ts | 412 ++++++++++++++ .../deepseek-account-platform/src/logout.ts | 37 ++ .../deepseek-account-platform/src/protocol.ts | 178 ++++++ .../deepseek-account-platform/src/types.ts | 2 + .../tests/account.spec.ts | 537 ++++++++++++++++++ .../tests/diagnostics.spec.ts | 55 ++ .../tests/expected/web-exchange-failure.txt | 1 + .../tests/logout.spec.ts | 75 +++ .../deepseek-account-platform/tsconfig.json | 33 ++ .../deepseek-account/README.i18n.yaml | 6 + .../credentials/deepseek-account/README.md | 55 ++ .../credentials/deepseek-account/README.zh.md | 55 ++ .../credentials/deepseek-account/package.json | 42 ++ .../credentials/deepseek-account/src/index.ts | 74 +++ .../credentials/deepseek-account/src/types.ts | 45 ++ .../deepseek-account/tsconfig.json | 21 + .../src/client/slot-catalog.ts | 79 ++- .../extensions/tool-cordis/src/api-catalog.ts | 103 +++- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 + packages/llm/llm-deepseek/README.zh.md | 4 + packages/llm/llm-deepseek/package.json | 11 +- packages/llm/llm-deepseek/src/adapter.ts | 1 + .../llm/llm-deepseek/src/common/file-store.ts | 3 + .../llm/llm-deepseek/src/common/files-api.ts | 9 +- packages/llm/llm-deepseek/src/common/types.ts | 2 + packages/llm/llm-deepseek/src/index.ts | 2 + .../src/protocols/chat-completions/adapter.ts | 10 +- .../src/protocols/messages/adapter.ts | 14 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 30 + .../llm/llm-deepseek/tests/files-api.spec.ts | 15 + packages/llm/llm-deepseek/tsconfig.json | 3 + .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 + .../test-support/client-runtime/README.zh.md | 2 + .../src/assembly/bundle-roster.ts | 15 +- .../src/assembly/remote-default-responses.ts | 5 + .../assembly-bundle-roster.client.spec.ts | 19 +- pnpm-lock.yaml | 150 +++++ scripts/gen-cordis-catalog.ts | 5 + scripts/gen-doc-graphs.ts | 9 + .../verify-package-readme-model-experience.ts | 6 + tsconfig.base.json | 9 + tsconfig.client.json | 1 + tsconfig.host.json | 4 + 208 files changed, 5773 insertions(+), 148 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md create mode 100644 .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md create mode 100644 apps/desktop/src/account-backend.ts create mode 100644 apps/desktop/src/platform-ipc.ts create mode 100644 apps/desktop/src/platform-view.ts create mode 100644 apps/desktop/src/preload-platform-account.ts create mode 100644 apps/desktop/tests/account-backend.spec.ts create mode 100644 apps/desktop/tests/expected/welcome/en-timeout.expected.txt create mode 100644 apps/desktop/tests/expected/welcome/zh-CN-timeout.expected.txt create mode 100644 apps/desktop/tests/platform-preload.e2e.ts create mode 100644 apps/desktop/tests/platform-view.spec.ts create mode 100644 docs/sign-in-readiness.i18n.yaml create mode 100644 docs/sign-in-readiness.md create mode 100644 docs/sign-in-readiness.zh.md create mode 100644 packages/api/account-controller/README.i18n.yaml create mode 100644 packages/api/account-controller/README.md create mode 100644 packages/api/account-controller/README.zh.md create mode 100644 packages/api/account-controller/package.json create mode 100644 packages/api/account-controller/src/index.ts create mode 100644 packages/api/account-controller/src/types.ts create mode 100644 packages/api/account-controller/tsconfig.json create mode 100644 packages/client/ui-settings-account/README.i18n.yaml create mode 100644 packages/client/ui-settings-account/README.md create mode 100644 packages/client/ui-settings-account/README.zh.md create mode 100644 packages/client/ui-settings-account/package.json create mode 100644 packages/client/ui-settings-account/src/client/AccountMenu.module.css create mode 100644 packages/client/ui-settings-account/src/client/AccountMenu.tsx create mode 100644 packages/client/ui-settings-account/src/client/AccountOnboarding.tsx create mode 100644 packages/client/ui-settings-account/src/client/AccountSection.module.css create mode 100644 packages/client/ui-settings-account/src/client/AccountSection.tsx create mode 100644 packages/client/ui-settings-account/src/client/LogoutIcon.tsx create mode 100644 packages/client/ui-settings-account/src/client/PlatformOverlay.module.css create mode 100644 packages/client/ui-settings-account/src/client/PlatformOverlay.tsx create mode 100644 packages/client/ui-settings-account/src/client/SignInDialog.module.css create mode 100644 packages/client/ui-settings-account/src/client/SignInDialog.tsx create mode 100644 packages/client/ui-settings-account/src/client/browser-login.ts create mode 100644 packages/client/ui-settings-account/src/client/contact-url.ts create mode 100644 packages/client/ui-settings-account/src/client/formatBalance.ts create mode 100644 packages/client/ui-settings-account/src/client/index.ts create mode 100644 packages/client/ui-settings-account/src/client/locales.ts create mode 100644 packages/client/ui-settings-account/src/contact-config.ts create mode 100644 packages/client/ui-settings-account/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-account/src/index.ts create mode 100644 packages/client/ui-settings-account/tests/account.client.spec.tsx create mode 100644 packages/client/ui-settings-account/tests/browser-login.client.spec.ts create mode 100644 packages/client/ui-settings-account/tests/contact-url.client.spec.ts create mode 100644 packages/client/ui-settings-account/tests/expected/account-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/account-signed-out-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/account-signed-out-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/account-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/details-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/details-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/login-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/login-timeout-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/login-timeout-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/login-waiting-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/login-waiting-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/login-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/menu-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/menu-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/platform-header-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/platform-header-zh.txt create mode 100644 packages/client/ui-settings-account/tests/expected/profile-before-balance.txt create mode 100644 packages/client/ui-settings-account/tests/formatBalance.client.spec.ts create mode 100644 packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx create mode 100644 packages/client/ui-settings-account/tsconfig.json create mode 100644 packages/client/ui-settings-account/tsdown.config.ts create mode 100644 packages/credentials/deepseek-account-platform/README.i18n.yaml create mode 100644 packages/credentials/deepseek-account-platform/README.md create mode 100644 packages/credentials/deepseek-account-platform/README.zh.md create mode 100644 packages/credentials/deepseek-account-platform/package.json create mode 100644 packages/credentials/deepseek-account-platform/src/details.ts create mode 100644 packages/credentials/deepseek-account-platform/src/index.ts create mode 100644 packages/credentials/deepseek-account-platform/src/logout.ts create mode 100644 packages/credentials/deepseek-account-platform/src/protocol.ts create mode 100644 packages/credentials/deepseek-account-platform/src/types.ts create mode 100644 packages/credentials/deepseek-account-platform/tests/account.spec.ts create mode 100644 packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts create mode 100644 packages/credentials/deepseek-account-platform/tests/expected/web-exchange-failure.txt create mode 100644 packages/credentials/deepseek-account-platform/tests/logout.spec.ts create mode 100644 packages/credentials/deepseek-account-platform/tsconfig.json create mode 100644 packages/credentials/deepseek-account/README.i18n.yaml create mode 100644 packages/credentials/deepseek-account/README.md create mode 100644 packages/credentials/deepseek-account/README.zh.md create mode 100644 packages/credentials/deepseek-account/package.json create mode 100644 packages/credentials/deepseek-account/src/index.ts create mode 100644 packages/credentials/deepseek-account/src/types.ts create mode 100644 packages/credentials/deepseek-account/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml new file mode 100644 index 0000000000..e2fb6b15cf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md +2026-09-14-deepseek-account-login.md: d570966b4758786195d5b3e43fff404d3d005bba +2026-09-14-deepseek-account-login.zh.md: 4b79bee13c855729088463d8b3a43cc785776a01 diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md new file mode 100644 index 0000000000..d570966b47 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md @@ -0,0 +1,55 @@ +# Agent Note: DeepSeek account browser login + +Status: implemented + +English | [中文](2026-09-14-deepseek-account-login.zh.md) + +Sign-in captures the initiating UI language per attempt so browser authorization follows the Desktop or Settings locale without depending on the Platform default. + +## Problem + +Desktop login must preserve local cancellation across the browser, UI, and Host and report success only after credentials are saved. + +## Decision + +The account Service Definition separates UI and model consumers from the platform protocol implementation. The platform provider registers an AuthorizationFlow and persists an owner-scoped GrantRecord. Account presence is derived from storage. Login attempts stay in memory and expose safe snapshots through the account Remote controller. + +Local cancellation is authoritative. The provider stops callbacks and ignores late exchange results; auth_cancel invalidates the remote application and unexchanged code using request_id and the original PKCE verifier. Its background request does not delay local cancellation, and failure never restores login. session.commit admits persistence synchronously before its first await and rejects an already-cancelled flow. Cancellation after admission waits for the commit result. The callback redirects to auth_exchange.biz_data.authorized_url only after authorization settlement confirms storage. + +Authorization registers a temporary /oauth/callback route on the Host webServer. The authenticated initiating client supplies its browser-accessible loopback HTTP origin, including any local SSH forwarding port. Non-loopback reverse proxies are unsupported. Cleanup removes only the route and preserves shared connections. Exchange failure returns Web to its login UI or focuses Desktop through account state; retry is explicit. PKCE secrets never cross UI transports. Model and file request tokens resolve only for https://api.deepseek.com; redirects are refused. Development loopback grants cannot authenticate production requests. API-key references remain separately stored. + +Platform navigation and authorization share one validated platformOrigin from Cordis configuration. Private profile patches or environment expressions supply deployment-specific origins without committing development addresses. + +## Alternatives considered + +Putting the protocol in Electron duplicates the implementation for Web consumers and gives the shell credential ownership. Putting tokens in DEEPSEEK_API_KEY loses the distinction between account grants and user API keys. Treating a browser callback as success before persistence admits false success. Waiting for a remote cancel acknowledgment leaves local cancellation dependent on network availability. These alternatives are rejected. + +## Consequences + +Sign out removes the local grant before starting Platform POST /auth-api/v0/users/logout with the captured token. Remote failure never restores login. The provider retries at most five times after the initial request, with configurable exponential backoff starting at one second by default. Retry jobs retain only their old token, cannot change later login state, and end at provider shutdown without durable storage. Local credential deletion must succeed before publishing signed-out state. Sign out belongs to the sidebar account menu; Account settings owns profile, balance and sign-in. Both read one plugin-owned Host stream through framework hooks. + +API keys and account grants remain separately stored. Account tokens have no expiry or refresh flow; exhausted logout retries can leave the removed token valid remotely. Profile and balance query failures preserve login state; authorization-attempt deadlines apply only before token issuance. The same grant authenticates Platform current and get_user_summary queries on its configured issuer origin. Host preserves Platform-masked contact details and drops response tokens; account changes invalidate pending results. UI shows normal_wallets recharge balances without adding bonus wallets. + +The account plugin supplies choice, waiting, failure and timeout dialogs through settings.models.sign-in. The model package owns credential readiness and the existing API-key editor; the settings shell coordinates explicit reopening so login and API-key onboarding do not mount competing dialogs. + +Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session. + +Support questionnaires receive only the Platform UID and available environment metadata through explicit prefill fields. Account tokens and masked contact details stay out of questionnaire URLs. The account provider projects the current-user UID for this purpose; the UI uses the latest account snapshot so sign-out cannot reuse a previous UID. + +Private proxy development can explicitly map authorization and completion URLs to platformOrigin with rewriteBrowserOrigin. Both pages retain their fixed paths and full query strings; allowing a remote origin without mapping it would send the browser outside the configured environment. Shipped configuration requires same-origin URLs. + +DSH grants authenticate Platform, inference and Files requests with x-dsh-auth-token, without a Bearer prefix. API-key authentication remains protocol-specific. + +Profile and recharge-wallet queries use independent getProfile and getBalance operations. The client publishes each result as it arrives, so a slow or failed balance request cannot delay the sidebar username. Account changes invalidate both pending results. + +## Verification + +Provider tests exercise real loopback callbacks, invalid state, delayed exchange cancellation, credential persistence, sign-out, and official-origin restrictions. Desktop tests cover the native action bridge and localized entry. Manual development integration uses the platform dev middleware Mock and the real Electron Host, including cancellation before browser approval. Production backend credentials and installer scheme registration require release-environment validation. + +## Related + +[Credential records and flows](2026-08-13-credential-records-and-authorization-flows.md) remains the generic credential authority. [Desktop wrapper](2026-09-10-desktop-web-wrapper.md) owns the transport composition. + +Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider. + +The Host sends client_type (desktop or web) in auth_init so Platform can choose the completion experience. Web failures close the authorization tab while the original tab receives Host state; no Web UI return URL is used. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal. diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md new file mode 100644 index 0000000000..4b79bee13c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md @@ -0,0 +1,55 @@ +# Agent Note: DeepSeek 账号浏览器登录 + +Status: implemented + +[English](2026-09-14-deepseek-account-login.md) | 中文 + +登录申请保存发起界面的语言,使浏览器授权跟随桌面或设置页的语言,不依赖平台默认值。 + +## 问题 + +桌面登录必须在浏览器、UI 和 Host 之间保留本地取消意图,并只在凭证保存后宣告成功。 + +## 决策 + +账号服务定义将 UI 和模型使用方与平台协议实现分开。平台提供者注册 AuthorizationFlow,并保存按所有者寻址的 GrantRecord。账号是否存在从存储派生。登录尝试保留在内存中,通过账号 Remote 控制器提供安全快照。 + +本地取消具有决定权。提供者停止回调并忽略迟到的兑换结果;auth_cancel 使用 request_id 和原始 PKCE verifier 作废远程申请及未兑换的 code。后台请求不延迟本地取消,失败也不会恢复登录。session.commit 在第一次 await 前同步准入持久化,并拒绝已取消的流程。准入后的取消等待提交结果。回调只有在授权结束确认存储后才跳转 auth_exchange.biz_data.authorized_url。 + +授权在 Host webServer 上注册临时 /oauth/callback 路由。已鉴权的发起客户端提供浏览器可访问的本机 HTTP 来源,包含 SSH 本地转发端口。不支持非本机域名反向代理。清理仅移除路由,保留共享连接。兑换失败返回 Web 登录界面,或通过账号状态聚焦 Desktop;重试由用户明确发起。PKCE 私密数据不经过 UI 传输。模型和文件请求的 token 仅为 https://api.deepseek.com 解析;请求拒绝重定向。本机开发授权记录不能认证生产请求。API Key 引用独立保存。 + +平台导航与授权共享 Cordis 配置中同一个经过校验的 platformOrigin。私有 profile patch 或环境表达式提供部署来源,开发地址不进入仓库。 + +## 替代方案 + +把协议放在 Electron 会为 Web 使用方重复实现,并让外壳拥有凭证。把 token 放入 DEEPSEEK_API_KEY 会丢失账号授权与用户 API Key 的区别。在持久化前将浏览器回调视为成功会产生虚假成功。等待远程取消确认会使本地取消依赖网络。这些方案均被拒绝。 + +## 影响 + +退出登录先删除本地授权,再使用捕获的 token 调用 Platform POST /auth-api/v0/users/logout。远程失败不会恢复登录态。首次请求失败后,提供者最多重试五次,使用可配置的指数退避,默认从一秒开始。重试任务只保留旧 token,不改变后续登录态,并在提供者关闭时结束,不持久化。本地凭证删除成功后才发布已退出状态。退出登录位于侧边栏账号菜单;账号设置负责资料、余额和登录。两者通过框架 hook 读取插件持有的同一条 Host 状态流。 + +API Key 与账号记录独立保存。账号 token 没有过期或刷新流程;退登重试耗尽后,已从本地删除的 token 在远端仍可能有效。资料和余额查询失败保留登录态;授权尝试的有效期仅适用于 token 签发前。同一授权 token 可认证已配置签发来源上的 Platform current 和 get_user_summary 查询。Host 原样保留 Platform 脱敏后的联系方式并丢弃响应 token;账号变化使未完成结果失效。UI 显示 normal_wallets 充值余额,不合并赠送钱包。 + +账号插件通过 settings.models.sign-in 提供选择、等待、失败和超时对话框。模型包保留凭证就绪检查和现有 API Key 编辑器;设置外壳协调显式重开,避免登录与 API Key 引导同时挂载冲突的对话框。 + +Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。 + +支持问卷仅通过显式预填字段接收 Platform UID 和已有的环境信息,URL 不包含账号 token 或脱敏联系方式。账号提供者为此投影 current-user 的 UID;UI 使用最新账号快照,避免退出登录后复用旧 UID。 + +私有代理开发可通过 rewriteBrowserOrigin 显式将授权页和完成页映射到 platformOrigin。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。 + +DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请求,不加 Bearer 前缀。API Key 继续使用各协议的认证方式。 + +资料和充值钱包余额分别通过 getProfile 与 getBalance 查询。客户端在各自返回时立即更新,余额请求慢或失败不会延迟侧边栏用户名显示。账号变化使两种进行中的查询结果失效。 + +## 验证 + +提供者测试覆盖真实本机回调、错误 state、延迟兑换取消、凭证持久化、退出和官方来源限制。桌面测试覆盖原生操作桥接与本地化入口。手动开发联调使用平台 dev middleware Mock 和真实 Electron Host,包括在浏览器批准前取消。生产后端凭证及安装器 scheme 注册仍需发布环境验证。 + +## 相关记录 + +[凭证记录与流程](2026-08-13-credential-records-and-authorization-flows.zh.md)仍是通用凭证依据。[桌面外壳](2026-09-10-desktop-web-wrapper.zh.md)负责传输组合。 + +开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。 + +Host 在 auth_init 中发送 client_type(desktop 或 web),供 Platform 选择完成页交互。Web 失败时关闭授权标签页,原标签页接收 Host 状态,不使用 Web UI 返回地址。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ec698dca33..ccd764f0db 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -69,6 +69,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`acorn`](https://github.com/acornjs/acorn) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | +| [`big.js`](https://github.com/MikeMcl/big.js) | MIT | | [`buffer`](https://github.com/feross/buffer) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Apache-2.0 | @@ -174,6 +175,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index fb7a946d7c..f6c99c4d79 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -48,6 +48,10 @@ flowchart LR cfg --> plugin_dsh_base_llm_retry plugin_dsh_base_settings["settings
@deepseek-ai/dsh-settings-file"] cfg --> plugin_dsh_base_settings + plugin_dsh_base_authorization["authorization
@deepseek-ai/dsh-authorization"] + cfg --> plugin_dsh_base_authorization + plugin_dsh_base_deepseek_account["deepseek-account
@deepseek-ai/dsh-deepseek-account-platform"] + cfg --> plugin_dsh_base_deepseek_account plugin_dsh_base_credentials["credentials
@deepseek-ai/dsh-credentials-local"] cfg --> plugin_dsh_base_credentials plugin_dsh_base_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] @@ -210,6 +214,8 @@ flowchart LR | `jobs` | `@deepseek-ai/dsh-jobs-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-file` | +| `authorization` | `@deepseek-ai/dsh-authorization` | +| `deepseek-account` | `@deepseek-ai/dsh-deepseek-account-platform` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 092e3c2813..fba5fda302 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -19,6 +19,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-skill-office": "workspace:^" + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-deepseek-account": "workspace:^" } } diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 573ef11541..9e8ecfc71c 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -5,6 +5,7 @@ import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot' import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' +import type {} from '@deepseek-ai/dsh-deepseek-account' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import * as desktopOffice from './office.ts' @@ -70,6 +71,26 @@ async function main(): Promise { source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'), root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'), }) + const account = ctx.get('deepseekAccount') + if (account !== undefined) { + const publish = async (): Promise => { + const session = await account.getPlatformSession() + if (process.connected) process.send?.({ type: 'platform-session', session }) + } + await publish() + ctx.effect(() => { + const lifetime = new AbortController() + const updates = (async () => { + for await (const _state of account.watch(lifetime.signal)) { + if (lifetime.signal.aborted) break + await publish() + } + })().catch(() => { + if (process.connected) process.send?.({ type: 'platform-session', session: null }) + }) + return async () => { lifetime.abort(); await updates } + }) + } const url = ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(ctx.webServer.port)}`) if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) }) } diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 89780f7b94..b077063d9b 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../packages/host/webserver" + }, + { + "path": "../../packages/credentials/deepseek-account" } ] } diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index f7cfd63f42..84d4a259ef 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -3,6 +3,7 @@ import type { AfterPackContext, BeforePackContext } from 'app-builder-lib' /** Electron-builder fields asserted by the Desktop release tests. */ export interface DesktopElectronBuilderConfig { readonly appId: string + readonly protocols: readonly [{ readonly name: 'DeepSeek Harness'; readonly schemes: readonly ['dsh'] }] readonly directories: { readonly output: string } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c060f1c673..0d7e652bad 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,9 @@ }, "dependencies": { "electron-updater": "^6.8.9", - "semver": "^7.8.5" + "semver": "^7.8.5", + "ws": "^8.21.0", + "@deepseek-ai/dsh-api-gateway": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", @@ -54,6 +56,8 @@ "js-yaml": "^4.2.0", "pnpm": "11.7.0", "tar": "^7.5.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "@deepseek-ai/dsh-deepseek-account": "workspace:^", + "@types/ws": "^8.18.1" } } diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index 2bfd791bb1..f3ae24cb45 100644 --- a/apps/desktop/renderer/welcome.css +++ b/apps/desktop/renderer/welcome.css @@ -282,3 +282,9 @@ body { clip-path: inset(50%); white-space: nowrap; } + +.auth-expired { gap: 12px; padding-top: 28px; } +.auth-expired h1 { font-size: 20px; line-height: 24px; font-weight: 400; letter-spacing: normal; } +.auth-expired p { line-height: 16px; letter-spacing: normal; } + +.welcome.expired-page { padding-bottom: 60px; } diff --git a/apps/desktop/renderer/welcome.html b/apps/desktop/renderer/welcome.html index 83248438c4..886b478e3f 100644 --- a/apps/desktop/renderer/welcome.html +++ b/apps/desktop/renderer/welcome.html @@ -24,8 +24,18 @@
+ +
- +
, document.body) } diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.module.css b/packages/client/ui-settings-account/src/client/SignInDialog.module.css index bef5c0dbd6..624e88e304 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.module.css +++ b/packages/client/ui-settings-account/src/client/SignInDialog.module.css @@ -9,7 +9,6 @@ .primaryButton { padding: 0 16px; font-weight: 500; } .secondaryButton { padding: 0 14px; border: 1px solid var(--dsw-alias-border-l2); } .description { margin: 0; padding: 0 24px; font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary); } -.browserDescription { text-decoration: underline; } .link { border: 0; padding: 0; background: transparent; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; } .spinner { flex: none; animation: spin 1s linear infinite; } .primaryButton:has(.spinner) { opacity: 1; } diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.tsx b/packages/client/ui-settings-account/src/client/SignInDialog.tsx index b589366bca..72fe91c318 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignInDialog.tsx @@ -45,7 +45,7 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
- {active ?

+ {active ?

{t('browserPrompt')}} items={[ - { id: 'settings', label: t('settings'), icon: }, - { id: 'contact', label: t('contactUs'), icon: }, + { id: 'settings', label: t('settings'), icon: }, + { id: 'contact', label: t('contactUs'), icon: }, ...(signedIn ? [{ id: 'signout', label: t('signOut'), icon: , disabled: busy }] - : [{ id: 'signin', label: t('signIn'), icon: }]), + : [{ id: 'signin', label: t('signIn'), icon: }]), ]} onClose={() => { setOpen(false) }} onSelect={(id) => { diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index ea24828d26..fc32f52eef 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -1,6 +1,6 @@ /** Account settings renders safe Host state and explicit login actions. */ import { useEffect, useState } from 'react' -import { Button, IconUserOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import type { PropsRuntime, PropsLocale, InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx' @@ -92,7 +92,7 @@ export function AccountSection({ t, useAccount, start, cancel, refresh, platform loadingLabel={t('loading')} failureLabel={t('failed')} onClose={() => { setPlatformPage(undefined) }} />}

- +
{signedIn ? profile?.name ?? t('signedIn') : t('signedOut')} {status} diff --git a/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx b/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx index e72c9d63ea..1945d9c727 100644 --- a/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx +++ b/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx @@ -1,7 +1,7 @@ /** Desktop Platform viewport; the native child owns remote content and credentials. */ import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { IconChevronLeftOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronLeftOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PlatformOverlay.module.css' /** Non-secret commands supplied by the desktop application preload. */ @@ -60,7 +60,7 @@ export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failure
diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.tsx b/packages/client/ui-settings-account/src/client/SignInDialog.tsx index 72fe91c318..8661dfbefb 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignInDialog.tsx @@ -1,6 +1,6 @@ /** Account authorization dialog shared by onboarding and explicit login. */ import { useEffect, useState } from 'react' -import { Button, IconCloseOutline16, IconLoadingOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconCloseOutlineRegular, IconLoadingOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountSnapshot } from './AccountSection.tsx' import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import type { AccountKey } from './locales.ts' @@ -42,7 +42,7 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {

{title}

{active ?

@@ -62,7 +62,7 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: { onClick={active ? dismiss : useApiKey}>{t(active ? 'cancel' : 'addApiKey')}

diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index e4c714b8ee..7a1f4fb920 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -15,7 +15,7 @@ import clsx from 'clsx' import { ConnectionIndicator, IconAgentPresetOutlineMedium, IconArchiveOutlineMedium, IconCloseOutlineRegular, IconDataOutlineMedium, - IconPersonalizationOutlineMedium, IconSettingsOutlineMedium, IconUserOutline16, + IconPersonalizationOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ConnectionIndicatorState } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts' @@ -29,7 +29,7 @@ const CONNECTING_MIN_VISIBLE_MS = 800 /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { - if (id === 'account') return + if (id === 'account') return if (id === 'models') return if (id === 'agent-presets') return if (id === 'plugins') return From 52d5ecd7c6ccd429070d2a0e4db10ad3afd997a9 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:22:50 +0800 Subject: [PATCH 018/121] fix(desktop): update welcome copy and remove forced preview --- ...6-09-08-desktop-welcome-window-material.i18n.yaml | 4 ++-- .../2026-09-08-desktop-welcome-window-material.md | 2 +- .../2026-09-08-desktop-welcome-window-material.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 10 ++-------- apps/desktop/README.zh.md | 10 ++-------- apps/desktop/renderer/welcome.css | 10 ++++++++++ apps/desktop/renderer/welcome.html | 7 +++++-- apps/desktop/renderer/welcome.js | 5 +++-- apps/desktop/scripts/dev.ts | 9 ++++----- apps/desktop/src/locale.ts | 12 +++++++----- apps/desktop/src/main.ts | 3 +-- apps/desktop/tests/development-app.spec.ts | 4 ++-- apps/desktop/tests/expected/welcome/en.expected.txt | 3 ++- .../tests/expected/welcome/zh-CN.expected.txt | 3 ++- apps/desktop/tests/welcome-renderer.spec.ts | 1 + apps/desktop/tests/welcome-startup.spec.ts | 11 ++++++----- 17 files changed, 53 insertions(+), 47 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml index 42ff4b7b0f..8fa0de1f1e 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md -2026-09-08-desktop-welcome-window-material.md: b0cb8a0bdd8abcf472597d5bfceaa175195718de -2026-09-08-desktop-welcome-window-material.zh.md: f917ad503c4348d9a44a862ba6d005c1e5ff0198 +2026-09-08-desktop-welcome-window-material.md: 33db25fc120db214273a353948b835a48089c850 +2026-09-08-desktop-welcome-window-material.zh.md: 4e5d0fef3d664ec1a0c220b26a37b60ee024c865 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md index b0cb8a0bdd..33db25fc12 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md @@ -14,7 +14,7 @@ The [Electron welcome window](../../../../apps/desktop/src/welcome-window.ts) ow Cold startup opens the entry when no model API key is configured. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a completion flag. The next process launch checks the credential store again. The Desktop preload marker suppresses only the Web credential step; settings and the welcome notice remain mounted. Other native shells can set `credentialOnboarding` through the Models Host plugin, which publishes that boolean through the page-injection event before dialogs register. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. The authentication decision accepts independent account and API-key facts, but account login and logout events have no connected provider yet, so startup supplies the current unsigned-in state. -The development launcher accepts `--preview-welcome` to force the entry while using the complete Desktop startup and operations. It shares the normal development home, profile, browser data, and single-instance lock. The flag affects only the initial page decision; saving and skipping use the production callbacks. Packaged startup does not activate that switch. The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins. +The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins. Desktop reads the shared `locale.preference` before opening the welcome window. The Client awaits an isolated preload read of that preference and the same OS language order before mounting, then reports resolved locale changes to the shell. Only an explicit Settings selection writes the preference; automatic detection stays provisional. The shell ships English and Simplified Chinese dictionaries, so a Client-only language pack falls back to a supported OS language in native windows. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md index f917ad503c..4e5d0fef3d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md @@ -14,7 +14,7 @@ Status: implemented 冷启动在没有已配置的模型 API Key 时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或完成标记。下次进程启动时会重新检查凭证存储。Desktop preload 标记只抑制 Web 凭证步骤;设置和欢迎须知仍然挂载。其他原生壳可以通过 Models Host 插件设置 `credentialOnboarding`,由页面注入事件在注册弹窗前发布该布尔值。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。认证判断接受独立的账号与 API Key 状态,但账号登录和退登事件尚无已接入的提供方,因此启动传入当前未登录状态。 -开发启动器接受 `--preview-welcome`,在使用完整 Desktop 启动流程和操作的同时强制显示入口。它共用普通开发环境的 home、profile、浏览器数据和单实例锁。该标志只影响初始页面判断;保存和跳过使用产品回调。打包后的启动不会激活该开关。生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。 +生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。 Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在挂载前等待隔离 preload 返回该偏好和同一系统语言顺序,之后将解析后的语言变更通知桌面壳。只有设置中的明确选择会写入偏好;自动检测保持临时状态。桌面壳自带英文和简体中文词典,因此仅在 Client 中提供的语言包会在原生窗口回退到受支持的系统语言。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 07f9e233f3..c9722998d8 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 884e1b6348a434dc1dfc768d50ef65f439662a05 -README.zh.md: f08c70acf920421ea6d70495880876df1624ed18 +README.md: 7df838d16e6876cbc90b7d5a49a082b85e3f958f +README.zh.md: 23157fc49a42d946ce09b11d5e7859a6b68912b0 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 884e1b6348..7df838d16e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -106,15 +106,9 @@ Desktop checks configured model API-key references after the Host starts and bef The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector. -### Welcome window preview +### Welcome window appearance -Build the complete Desktop application and force its welcome page, including when a key is already configured: - -```sh -pnpm run dev:desktop --preview-welcome -``` - -The preview uses the same Host, development project, Harness home, browser data, and single-instance lock as normal development startup. It follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and an Old Style serif stack for the tagline. The window uses macOS titlebar vibrancy or Windows acrylic behind a transparent renderer, without an additional white overlay or whole-window opacity reduction. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The flag changes only the initial welcome-page decision. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions. +The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and an Old Style serif stack for the product name in the welcome heading. The window uses macOS titlebar vibrancy or Windows acrylic behind a transparent renderer, without an additional white overlay or whole-window opacity reduction. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions. ## Package diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f08c70acf9..23157fc49a 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -107,15 +107,9 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否 欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。 -### 欢迎窗口预览 +### 欢迎窗口外观 -构建完整 Desktop 应用并强制显示欢迎页,即使已有配置好的密钥: - -```sh -pnpm run dev:desktop --preview-welcome -``` - -预览与普通开发启动共用 Host、开发项目、Harness home、浏览器数据和单实例锁。它使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及标语使用的 Old Style 衬线字体栈。窗口在透明渲染器后使用 macOS titlebar vibrancy 或 Windows acrylic,不额外叠加白色遮罩,也不降低整个窗口的不透明度。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。该标志只改变首次是否展示欢迎页的判断。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。 +欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及欢迎标题中产品名使用的 Old Style 衬线字体栈。窗口在透明渲染器后使用 macOS titlebar vibrancy 或 Windows acrylic,不额外叠加白色遮罩,也不降低整个窗口的不透明度。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。 ## 打包 diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index f3ae24cb45..698c2ec3ff 100644 --- a/apps/desktop/renderer/welcome.css +++ b/apps/desktop/renderer/welcome.css @@ -104,6 +104,16 @@ body { white-space: nowrap; } +.tagline h1, +.tagline p { + margin: 0; + font: inherit; +} + +.tagline p { + margin-top: 8px; +} + .tagline em { color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-family-serif); diff --git a/apps/desktop/renderer/welcome.html b/apps/desktop/renderer/welcome.html index 886b478e3f..6903ff1bf6 100644 --- a/apps/desktop/renderer/welcome.html +++ b/apps/desktop/renderer/welcome.html @@ -10,9 +10,12 @@ -
+
-

+
+

+

+