From 6ed5df964e951b73ef5d8864a1e418cd2b16af9c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:46:41 -0700 Subject: [PATCH] feat(web): add read-only Loader plugin inventory --- apps/web/tests/plugin-config.e2e.ts | 4 +- apps/web/tests/settings-chrome.e2e.ts | 26 ++- .../created.expected.md | 3 + .../damaged.expected.md | 3 + .../section.expected.md | 3 + .../models-settings/configured.expected.md | 3 + .../models-settings/declared-edit.expected.md | 3 + .../models-settings/declared.expected.md | 3 + .../models-settings/empty.expected.md | 3 + .../models.expected.md | 3 + .../plugin-config/section.expected.md | 3 + .../settings-chrome/dialog.expected.md | 3 + .../settings-chrome/plugins.expected.md | 4 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 17 +- docs/module-graph.zh.md | 17 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 +- packages/api/remotes/README.zh.md | 3 +- packages/api/remotes/package.json | 2 + packages/api/remotes/src/client/index.ts | 4 +- packages/api/remotes/tsconfig.client.json | 3 + packages/bundle/web-app/cordis.patch.yml | 7 + packages/bundle/web-app/package.json | 2 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + packages/client/ui-plugins/README.i18n.yaml | 6 + packages/client/ui-plugins/README.md | 20 ++ packages/client/ui-plugins/README.zh.md | 20 ++ packages/client/ui-plugins/package.json | 80 +++++++ .../client/PluginSettingsSection.module.css | 213 ++++++++++++++++++ .../src/client/PluginSettingsSection.tsx | 149 ++++++++++++ .../client/ui-plugins/src/client/index.ts | 42 ++++ .../client/ui-plugins/src/client/locales.ts | 46 ++++ .../client/ui-plugins/src/css-modules.d.ts | 6 + packages/client/ui-plugins/src/index.ts | 4 + packages/client/ui-plugins/src/invariant.ts | 20 ++ .../ui-plugins/tests/browser-plugin.spec.tsx | 87 +++++++ .../ui-plugins/tests/components.spec.tsx | 100 ++++++++ .../client/ui-plugins/tests/invariant.spec.ts | 15 ++ packages/client/ui-plugins/tsconfig.json | 36 +++ packages/client/ui-plugins/tsdown.config.ts | 3 + packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 1 + packages/host/README.zh.md | 1 + .../host/plugin-inventory/README.i18n.yaml | 6 + packages/host/plugin-inventory/README.md | 22 ++ packages/host/plugin-inventory/README.zh.md | 22 ++ packages/host/plugin-inventory/package.json | 70 ++++++ packages/host/plugin-inventory/src/index.ts | 72 ++++++ .../host/plugin-inventory/src/invariant.ts | 20 ++ packages/host/plugin-inventory/src/types.ts | 28 +++ .../plugin-inventory/tests/invariant.spec.ts | 16 ++ .../plugin-inventory/tests/inventory.spec.ts | 89 ++++++++ packages/host/plugin-inventory/tsconfig.json | 27 +++ pnpm-lock.yaml | 73 ++++++ .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 64 files changed, 1429 insertions(+), 19 deletions(-) create mode 100644 apps/web/tests/snapshots/settings-chrome/plugins.expected.md create mode 100644 packages/client/ui-plugins/README.i18n.yaml create mode 100644 packages/client/ui-plugins/README.md create mode 100644 packages/client/ui-plugins/README.zh.md create mode 100644 packages/client/ui-plugins/package.json create mode 100644 packages/client/ui-plugins/src/client/PluginSettingsSection.module.css create mode 100644 packages/client/ui-plugins/src/client/PluginSettingsSection.tsx create mode 100644 packages/client/ui-plugins/src/client/index.ts create mode 100644 packages/client/ui-plugins/src/client/locales.ts create mode 100644 packages/client/ui-plugins/src/css-modules.d.ts create mode 100644 packages/client/ui-plugins/src/index.ts create mode 100644 packages/client/ui-plugins/src/invariant.ts create mode 100644 packages/client/ui-plugins/tests/browser-plugin.spec.tsx create mode 100644 packages/client/ui-plugins/tests/components.spec.tsx create mode 100644 packages/client/ui-plugins/tests/invariant.spec.ts create mode 100644 packages/client/ui-plugins/tsconfig.json create mode 100644 packages/client/ui-plugins/tsdown.config.ts create mode 100644 packages/host/plugin-inventory/README.i18n.yaml create mode 100644 packages/host/plugin-inventory/README.md create mode 100644 packages/host/plugin-inventory/README.zh.md create mode 100644 packages/host/plugin-inventory/package.json create mode 100644 packages/host/plugin-inventory/src/index.ts create mode 100644 packages/host/plugin-inventory/src/invariant.ts create mode 100644 packages/host/plugin-inventory/src/types.ts create mode 100644 packages/host/plugin-inventory/tests/invariant.spec.ts create mode 100644 packages/host/plugin-inventory/tests/inventory.spec.ts create mode 100644 packages/host/plugin-inventory/tsconfig.json diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index bdad2083aa..15956877f4 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -56,9 +56,9 @@ describe('web e2e: plugin configuration section', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '插件' }).click() + await dialog.getByRole('button', { name: '插件配置', exact: true }).click() await expect - .poll(() => dialog.getByRole('button', { name: '插件' }).getAttribute('aria-current'), { timeout: 5_000 }) + .poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) .toBe('true') return dialog } diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index a3b387b660..5d057a7bed 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -23,6 +23,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') +const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md') +const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]' const MODE = webSnapshotMode() describe('web e2e: settings modal and General preferences', () => { @@ -92,6 +94,28 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '模型' }).click() await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() + // Plugins is a read-only projection of the same assembled Loader tree. + // Capture one stable shipped row rather than the whole inventory so adding + // an unrelated plugin does not rewrite this surface's golden. + await dialog.getByRole('button', { name: '插件', exact: true }).click() + await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) + const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) + await pluginRow.waitFor({ timeout: 10_000 }) + const expectedPluginCount = [...scaffold.ctx.loader.entries()] + .filter(entry => !entry.options.group) + .length + expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1) + expect(await dialog.locator('[data-plugin-entry]').count()).toBe(expectedPluginCount) + expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) + .toBe(String(expectedPluginCount)) + expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() + const pluginsSnapshot = await captureStableAria( + page, + PLUGIN_ROW_SELECTOR, + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(PLUGINS_EXPECTED, pluginsSnapshot, MODE) // Close path 1: Escape. await page.keyboard.press('Escape') await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) @@ -454,6 +478,6 @@ describe('web e2e: settings modal and General preferences', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index fc96442a85..40b9497831 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index b8136ec702..c3e9035098 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index 9bc3d8db4d..e411e8ea28 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index e4fb6e13e8..66cc7c88b6 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md index 1acd03b4aa..86f8b77fe8 100644 --- a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index b126a5025b..b9e5dca61f 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 5a1dba54ee..03e87a7a51 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 2624f4db70..562b54d837 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/plugin-config/section.expected.md b/apps/web/tests/snapshots/plugin-config/section.expected.md index 7d10d05cd1..18cdef13d5 100644 --- a/apps/web/tests/snapshots/plugin-config/section.expected.md +++ b/apps/web/tests/snapshots/plugin-config/section.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 914293aee3..2a7c767bf8 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/settings-chrome/plugins.expected.md b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md new file mode 100644 index 0000000000..e3b2e9ee54 --- /dev/null +++ b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md @@ -0,0 +1,4 @@ +- listitem "ui-settings, 存活, 已启用": + - strong: ui-settings + - img "存活" + - text: 已启用 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7b380a0698..833002c14a 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: 2bb1f315e02c3f4bab379593227f1c381818a9fa -config-catalog.zh.md: 4be51942de65a7e8afdaf71a44573ba1cdd6230d +config-catalog.md: 83e0d3e3a8bd1b5f0b5d70e1552e0fd9b70eccf1 +config-catalog.zh.md: 21cf904708c5eae1c71b2c85843eb80926a4912e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2bb1f315e0..83e0d3e3a8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2770,6 +2770,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plugin-config` ([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugins` ([`packages/client/ui-plugins/src/index.ts`](../packages/client/ui-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/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)) @@ -2792,6 +2793,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) +- `@deepseek-ai/dsh-host-plugin-inventory` — requires `loader` ([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4be51942de..21cf904708 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2771,6 +2771,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-permission`([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plugin-config`([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugins`([`packages/client/ui-plugins/src/index.ts`](../packages/client/ui-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/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)) @@ -2793,6 +2794,7 @@ export interface Config { - `@deepseek-ai/dsh-goal-session` — 需要 `agents` · `goals` · `sessions`([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — 需要 `httpServer` · `loader`([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native`([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) +- `@deepseek-ai/dsh-host-plugin-inventory` — 需要 `loader`([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-llm`([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp`([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty`([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 5b26c5608a..cbd76b01e5 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: e24d2601fd74f58eec8c5e5b1a35701e1800baaa -module-graph.zh.md: b71da431b05634c4c599096c8cd33fed23a6be45 +module-graph.md: 9ca575137f3ebdddbc77e89c3b3b97da90c94843 +module-graph.zh.md: 6978ff30b7e8e927a0d0a10dd7b06cfbbeb1f7e9 diff --git a/docs/module-graph.md b/docs/module-graph.md index e24d2601fd..9ca575137f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -162,6 +162,7 @@ flowchart TD pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_plugin_config["client-ui-plugin-config"] + pkg_client_ui_plugins["client-ui-plugins"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -219,6 +220,7 @@ flowchart TD pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_directory_picker_native["host-directory-picker-native"] + pkg_host_plugin_inventory["host-plugin-inventory"] pkg_host_webserver["host-webserver"] end subgraph group_interaction["packages/interaction"] @@ -358,6 +360,9 @@ flowchart TD pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_type_meta pkg_user_id --> pkg_brand pkg_user_id --> pkg_invariants pkg_user_id --> pkg_paths @@ -642,6 +647,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_session @@ -1136,6 +1142,13 @@ flowchart TD pkg_client_ui_plugin_config --> pkg_client_ui_slots pkg_client_ui_plugin_config --> pkg_client_web_react pkg_client_ui_plugin_config --> pkg_invariants + pkg_client_ui_plugins --> pkg_api_remotes + pkg_client_ui_plugins --> pkg_client_locale + pkg_client_ui_plugins --> pkg_client_runtime + pkg_client_ui_plugins --> pkg_client_ui_primitives + pkg_client_ui_plugins --> pkg_client_ui_settings + pkg_client_ui_plugins --> pkg_client_ui_slots + pkg_client_ui_plugins --> pkg_invariants pkg_client_ui_question --> pkg_api_remotes pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants @@ -1374,6 +1387,7 @@ flowchart TD | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta) | | [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1443,7 +1457,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1522,6 +1536,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-plugins`](../packages/client/ui-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b71da431b0..6978ff30b7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -164,6 +164,7 @@ flowchart TD pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_plugin_config["client-ui-plugin-config"] + pkg_client_ui_plugins["client-ui-plugins"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -221,6 +222,7 @@ flowchart TD pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_directory_picker_native["host-directory-picker-native"] + pkg_host_plugin_inventory["host-plugin-inventory"] pkg_host_webserver["host-webserver"] end subgraph group_interaction["packages/interaction"] @@ -360,6 +362,9 @@ flowchart TD pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_type_meta pkg_user_id --> pkg_brand pkg_user_id --> pkg_invariants pkg_user_id --> pkg_paths @@ -644,6 +649,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_session @@ -1138,6 +1144,13 @@ flowchart TD pkg_client_ui_plugin_config --> pkg_client_ui_slots pkg_client_ui_plugin_config --> pkg_client_web_react pkg_client_ui_plugin_config --> pkg_invariants + pkg_client_ui_plugins --> pkg_api_remotes + pkg_client_ui_plugins --> pkg_client_locale + pkg_client_ui_plugins --> pkg_client_runtime + pkg_client_ui_plugins --> pkg_client_ui_primitives + pkg_client_ui_plugins --> pkg_client_ui_settings + pkg_client_ui_plugins --> pkg_client_ui_slots + pkg_client_ui_plugins --> pkg_invariants pkg_client_ui_question --> pkg_api_remotes pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants @@ -1376,6 +1389,7 @@ flowchart TD | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta) | | [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1445,7 +1459,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1524,6 +1538,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-plugins`](../packages/client/ui-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 421a56d951..35117e9dda 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/api/remotes/README.md -README.md: cc903af7204ca715c6c7931cfe44823d4d5fc71e -README.zh.md: fe34b8774c9864cef442ff8a58f22f541d40768a +README.md: 288c63c9f43654dfec428a6a8955dc537efe81a6 +README.zh.md: 1fd599b08ecc1b4ae1dba738ce8946aa4eab5946 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index cc903af720..288c63c9f4 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -6,7 +6,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos `createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. -The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index fe34b8774c..1fd599b08e 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -6,8 +6,7 @@ `createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 -当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 - +当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 30ebcb3195..5c643a2aea 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", @@ -78,6 +79,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index c08190ddc1..2f077dfd75 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -3,11 +3,13 @@ import type { Context } from '@deepseek-ai/cordis' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -54,7 +56,7 @@ export const inject = ['remote'] export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { - for (const contribution of [commandsRemote, goalsRemote]) { + for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote]) { disposers.push(await ctx.remote.$mount(contribution)) } } catch (error) { diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 8f28b83a6e..274570dc52 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -26,6 +26,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../host/plugin-inventory" + }, { "path": "../../interaction/commands" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c696b41503..a5d039e3dd 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -80,6 +80,10 @@ - id: directory-picker name: '@deepseek-ai/dsh-host-directory-picker-auto' + # Read-only projection of current Loader entries for trusted client RPCs. + - id: plugin-inventory + name: '@deepseek-ai/dsh-host-plugin-inventory' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -172,6 +176,9 @@ - id: ui-models name: '@deepseek-ai/dsh-client-ui-models' + - id: ui-plugins + name: '@deepseek-ai/dsh-client-ui-plugins' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3375fed71a..efd11b896d 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index c5da6b743a..cd9f668fad 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: 75abe408952ed66dcc237ce489e417f61159bcc3 -README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca +README.md: 236531281c17ef982982e97caad99491584bd0b5 +README.zh.md: e619ffaa6341f509537342bde90344141d4c8f64 diff --git a/packages/client/README.md b/packages/client/README.md index 75abe40895..236531281c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -41,6 +41,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`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-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | +| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. | Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5432efcb0a..e619ffaa63 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -41,6 +41,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | +| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 | 每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。 diff --git a/packages/client/ui-plugins/README.i18n.yaml b/packages/client/ui-plugins/README.i18n.yaml new file mode 100644 index 0000000000..ce5113cde1 --- /dev/null +++ b/packages/client/ui-plugins/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-plugins/README.md +README.md: c3236935b2a568c6996fcac8469f061a813cd95c +README.zh.md: 77810bccf95a55761518f89909fddd99818f686a diff --git a/packages/client/ui-plugins/README.md b/packages/client/ui-plugins/README.md new file mode 100644 index 0000000000..c3236935b2 --- /dev/null +++ b/packages/client/ui-plugins/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-plugins + +English | [中文](README.zh.md) + +Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). + +The page renders a searchable two-column catalog of compact cards. Each card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. + +## Model Experience + +None, as this package only visualizes a Host-owned deployment snapshot in browser Settings and registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot. +- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls. diff --git a/packages/client/ui-plugins/README.zh.md b/packages/client/ui-plugins/README.zh.md new file mode 100644 index 0000000000..77810bccf9 --- /dev/null +++ b/packages/client/ui-plugins/README.zh.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-plugins + +[English](README.md) | 中文 + +Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 + +页面以可搜索的双列紧凑卡片展示清单。每张卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 + +## 模型体验 + +无,因为本包只在浏览器设置中展示 Host 拥有的部署快照,不注册任何模型接口。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。 +- **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。 diff --git a/packages/client/ui-plugins/package.json b/packages/client/ui-plugins/package.json new file mode 100644 index 0000000000..cca1843929 --- /dev/null +++ b/packages/client/ui-plugins/package.json @@ -0,0 +1,80 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-plugins", + "description": "Read-only Cordis Loader plugin inventory in Web settings", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-plugins" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.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-api-remotes", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css new file mode 100644 index 0000000000..eb26d02298 --- /dev/null +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -0,0 +1,213 @@ +.section { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + max-width: 760px; + color: var(--dsw-alias-label-primary); +} + +.heading h2, +.catalogHeading h3, +.status, +.failure p { + margin: 0; +} + +.heading h2 { + font-size: 16px; + line-height: 24px; + font-weight: 600; +} + +.status, +.failure { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.failure { + display: flex; + align-items: center; + gap: 10px; + color: var(--dsw-alias-state-error-primary); +} + +.failure button { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + padding: 4px 10px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + cursor: pointer; +} + +.catalog { + display: flex; + flex-direction: column; + gap: 12px; +} + +.search { + position: relative; + display: flex; + align-items: center; + width: 100%; + color: var(--dsw-alias-label-tertiary); +} + +.search > svg { + position: absolute; + left: 12px; + pointer-events: none; +} + +.search input { + width: 100%; + height: 36px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 0 34px 0 36px; + outline: none; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; +} + +.search input::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.search input:focus-visible { + border-color: var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent); +} + +.catalogHeading { + display: flex; + align-items: baseline; + gap: 7px; + padding: 0 2px; +} + +.catalogHeading h3 { + font-size: 13px; + line-height: 20px; + font-weight: 600; +} + +.catalogHeading span { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); + font-variant-numeric: tabular-nums; +} + +.cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-3); +} + +.cardContent { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 52px; + border: 0; + padding: 12px 14px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +.cardTitle { + min-width: 0; + overflow: hidden; + font-size: 14px; + line-height: 20px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardTrailing { + display: inline-flex; + flex: none; + align-items: center; + gap: 7px; + color: var(--dsw-alias-label-tertiary); +} + +.statusDot { + display: inline-block; + width: 7px; + height: 7px; + flex: none; + border-radius: 999px; + background: var(--dsw-alias-label-tertiary); +} + +.statusDot[data-phase='active'] { + background: var(--dsw-alias-state-success-primary); +} + +.statusDot[data-phase='failed'] { + background: var(--dsw-alias-state-error-primary); +} + +.statusDot[data-phase='loading'] { + background: var(--dsw-alias-state-business-primary); +} + +.configTag { + display: inline-flex; + align-items: center; + min-height: 20px; + border-radius: 5px; + padding: 1px 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} + +.configTag[data-enabled='true'] { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +@media (max-width: 680px) { + .cards { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx new file mode 100644 index 0000000000..2d33ac2e8b --- /dev/null +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -0,0 +1,149 @@ +import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PluginsKey } from './locales.ts' +import css from './PluginSettingsSection.module.css' + +/** Registration-side Remote face used by the section. */ +export interface PluginSettingsSectionInjected { + /** Read a current Host inventory snapshot. */ + list: ClientRemote['pluginInventory']['list'] +} + +type PluginInventorySnapshot = Awaited> +type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] +type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] + +/** Full component props assembled by the Settings slot renderer. */ +export type PluginSettingsSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.plugins'> + & InjectFace + +type ViewState = + | { readonly status: 'loading' } + | { readonly status: 'error' } + | { readonly status: 'ready'; readonly snapshot: PluginInventorySnapshot } + +const PHASE_KEYS = { + pending: 'pending', + loading: 'loadingPhase', + active: 'active', + failed: 'failed', + unloading: 'unloading', +} satisfies Record, PluginsKey> + +/** Localized accessible label for one root Fiber phase. */ +function phaseLabel( + phase: PluginFiberPhase, + t: PluginSettingsSectionProps['t'], +): string { + return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) +} + +/** Whether an inventory row matches the local catalog query. */ +function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean { + if (normalizedQuery.length === 0) return true + return [entry.displayId, entry.entryId] + .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) +} + +/** Render the read-only current Loader inventory. */ +export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode { + const titleId = useId() + const [request, setRequest] = useState(0) + const [query, setQuery] = useState('') + const [state, setState] = useState({ status: 'loading' }) + + useEffect(() => { + let current = true + void Promise.resolve().then(() => list()).then( + (snapshot) => { if (current) setState({ status: 'ready', snapshot }) }, + () => { if (current) setState({ status: 'error' }) }, + ) + return () => { current = false } + }, [list, request]) + + const normalizedQuery = query.trim().toLocaleLowerCase() + const filteredEntries = useMemo( + () => state.status === 'ready' + ? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery)) + : [], + [normalizedQuery, state], + ) + + const retry = (): void => { + setState({ status: 'loading' }) + setRequest(value => value + 1) + } + + return ( +
+
+

{t('title')}

+
+ {state.status === 'loading' ?

{t('loading')}

: null} + {state.status === 'error' ? ( +
+

{t('error')}

+ +
+ ) : null} + {state.status === 'ready' ? ( +
+ +
+

{t('catalog')}

+ {filteredEntries.length} +
+ {state.snapshot.entries.length === 0 ?

{t('empty')}

: null} + {state.snapshot.entries.length > 0 && filteredEntries.length === 0 + ?

{t('emptySearch')}

+ : null} + {filteredEntries.length > 0 ? ( +
    + {filteredEntries.map((entry) => { + const status = phaseLabel(entry.fiberPhase, t) + return ( +
  • +
    + {entry.displayId} + + + + {t(entry.enabled ? 'enabledTag' : 'disabledTag')} + + +
    +
  • + ) + })} +
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts new file mode 100644 index 0000000000..b3034a86f9 --- /dev/null +++ b/packages/client/ui-plugins/src/client/index.ts @@ -0,0 +1,42 @@ +/** Read-only Host plugin inventory registered into Web Settings. */ + +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { PluginSettingsSection, type PluginSettingsSectionInjected } from './PluginSettingsSection.tsx' +import { en, zh, type PluginsKey } from './locales.ts' + +export type { PluginSettingsSectionInjected, PluginSettingsSectionProps } from './PluginSettingsSection.tsx' +export type { PluginsKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Read-only Host plugin inventory copy. */ + 'settings.plugins': PluginsKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.plugins' + +/** Services required by the Settings registration and generated Remote face. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory'] + +/** Register the lazy plugin inventory page below Models in Settings. */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') + + const t = ctx.locale.bind(NS) + const list: ClientRemote['pluginInventory']['list'] = () => ctx.remote.pluginInventory.list() + const injected = (): PluginSettingsSectionInjected => ({ list }) + + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'plugin-inventory', + order: 15, + label: () => t('nav'), + locale: NS, + inject: injected, + }, PluginSettingsSection)) +} diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts new file mode 100644 index 0000000000..64745ce14f --- /dev/null +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -0,0 +1,46 @@ +/** Copy dictionaries for the plugin inventory Settings section. */ + +/** Simplified Chinese dictionary and key source of truth. */ +export const zh = { + nav: '插件', + title: '插件', + loading: '正在读取插件…', + error: '暂时无法读取插件。', + retry: '重试', + search: '搜索插件', + catalog: '插件列表', + empty: '暂无插件。', + emptySearch: '没有匹配的插件。', + enabledTag: '已启用', + disabledTag: '已停用', + unobserved: '无根 Fiber', + pending: '等待依赖', + loadingPhase: '加载中', + active: '存活', + failed: '失败', + unloading: '卸载中', +} satisfies Record + +/** Plugin inventory locale key union. */ +export type PluginsKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + nav: 'Plugins', + title: 'Plugins', + loading: 'Reading plugins…', + error: 'Plugins are temporarily unavailable.', + retry: 'Retry', + search: 'Search plugins', + catalog: 'Plugin list', + empty: 'No plugins are available.', + emptySearch: 'No matching plugins.', + enabledTag: 'Enabled', + disabledTag: 'Disabled', + unobserved: 'No root Fiber', + pending: 'Pending', + loadingPhase: 'Loading', + active: 'Active', + failed: 'Failed', + unloading: 'Unloading', +} satisfies Record diff --git a/packages/client/ui-plugins/src/css-modules.d.ts b/packages/client/ui-plugins/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-plugins/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-plugins/src/index.ts b/packages/client/ui-plugins/src/index.ts new file mode 100644 index 0000000000..489544a421 --- /dev/null +++ b/packages/client/ui-plugins/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the plugin settings section. */ +export function apply(): void {} diff --git a/packages/client/ui-plugins/src/invariant.ts b/packages/client/ui-plugins/src/invariant.ts new file mode 100644 index 0000000000..2d001d4312 --- /dev/null +++ b/packages/client/ui-plugins/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-plugins/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugins' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-plugins-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this package owns a read-only Settings contribution. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-plugins/tests/browser-plugin.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.spec.tsx new file mode 100644 index 0000000000..c2e3c741fd --- /dev/null +++ b/packages/client/ui-plugins/tests/browser-plugin.spec.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject, NS } from '../src/client/index.ts' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx' + +usePinnedBrowserLanguages('zh-CN') +afterEach(cleanup) + +const EMPTY = { entries: [] } + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + const list = vi.fn(() => Promise.resolve(EMPTY)) + ctx.provide('remote.pluginInventory', { list }) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, list } +} + +function declare(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-plugins browser plugin', () => { + it('declares only the services used by the Settings Remote contribution', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory']) + }) + + it('registers a localized section without reading the Remote eagerly', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(PluginSettingsSection) + expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 }) + expect(entry.locale).toBe(NS) + expect(resolveSlotLabel(entry.options.label)).toBe('插件') + expect(b.list).not.toHaveBeenCalled() + + const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() + await expect(injected.list()).resolves.toEqual(EMPTY) + expect(b.list).toHaveBeenCalledOnce() + await b.ctx.fiber.dispose() + }) + + it('follows locale and recovers across late declaration and declarer reload', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('settings.section')).toHaveLength(0) + + const stop = declare(b.slots) + await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) + b.locale.setLocale('en') + expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins') + + stop() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await vi.waitFor(() => { + expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection) + }) + + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(() => b.locale.register(NS, 'zh', {})).not.toThrow() + await b.ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tests/components.spec.tsx b/packages/client/ui-plugins/tests/components.spec.tsx new file mode 100644 index 0000000000..af49f96ad5 --- /dev/null +++ b/packages/client/ui-plugins/tests/components.spec.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { + PluginSettingsSectionInjected, + PluginSettingsSectionProps, +} from '../src/client/PluginSettingsSection.tsx' +import { en, type PluginsKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Snapshot = Awaited> +const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t'] +const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never + +function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps { + return { + close: vi.fn(), + useSessions: unusedHook, + useWorkspaces: unusedHook, + t, + list, + } +} + +const SNAPSHOT = { + entries: [ + { entryId: 'active', displayId: 'active-name', enabled: true, fiberPhase: 'active' }, + { entryId: 'pending', displayId: 'pending-name', enabled: true, fiberPhase: 'pending' }, + { entryId: 'loading', displayId: 'loading-name', enabled: true, fiberPhase: 'loading' }, + { entryId: 'failed', displayId: 'failed-name', enabled: true, fiberPhase: 'failed' }, + { entryId: 'unloading', displayId: 'unloading-name', enabled: true, fiberPhase: 'unloading' }, + { entryId: 'disabled-entry', displayId: 'disabled-name', enabled: false, fiberPhase: null }, + ], +} as unknown as Snapshot + +describe('PluginSettingsSection', () => { + it('renders searchable two-column-card semantics with dots and tags', async () => { + const deferred = Promise.withResolvers() + const list = vi.fn(() => deferred.promise) + const view = render() + expect(screen.getByText(en.loading)).toBeTruthy() + + await act(async () => { deferred.resolve(SNAPSHOT) }) + expect(list).toHaveBeenCalledOnce() + expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6') + expect(screen.getAllByRole('listitem')).toHaveLength(6) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) + expect(screen.getByText(en.disabledTag)).toBeTruthy() + for (const value of ['Active', 'Pending', 'Loading', 'Failed', 'Unloading', 'No root Fiber']) { + expect(screen.getByRole('img', { name: value })).toBeTruthy() + } + expect(screen.getByRole('listitem', { name: 'active-name, Active, Enabled' })).toBeTruthy() + }) + + it('filters by local id or Loader entry id', async () => { + render( SNAPSHOT)} />) + const search = await screen.findByRole('searchbox', { name: en.search }) + + fireEvent.change(search, { target: { value: 'disabled-entry' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('disabled-name')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'pending' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('pending-name')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'not-a-plugin' } }) + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + expect(screen.getByText(en.emptySearch)).toBeTruthy() + }) + + it('shows a generic failure and retries into the empty state', async () => { + const list = vi.fn() + .mockRejectedValueOnce(new Error('private transport detail')) + .mockResolvedValueOnce({ entries: [] }) + render() + + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + expect(screen.queryByText('private transport detail')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) }) + expect(await screen.findByText(en.empty)).toBeTruthy() + }) + + it('contains a synchronous Remote failure and ignores a result after unmount', async () => { + const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list'] + const failed = render() + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + failed.unmount() + + const deferred = Promise.withResolvers() + const pending = render( deferred.promise)} />) + pending.unmount() + await act(async () => { deferred.resolve(SNAPSHOT) }) + }) +}) diff --git a/packages/client/ui-plugins/tests/invariant.spec.ts b/packages/client/ui-plugins/tests/invariant.spec.ts new file mode 100644 index 0000000000..df4161cb13 --- /dev/null +++ b/packages/client/ui-plugins/tests/invariant.spec.ts @@ -0,0 +1,15 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginsInvariant from '../src/invariant.ts' + +describe('ui-plugins invariant companion', () => { + it('registers the empty installer and keeps the node half inert', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined() + const { apply } = await import('../src/index.ts') + apply() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tsconfig.json b/packages/client/ui-plugins/tsconfig.json new file mode 100644 index 0000000000..2019585ff7 --- /dev/null +++ b/packages/client/ui-plugins/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-plugins/tsdown.config.ts b/packages/client/ui-plugins/tsdown.config.ts new file mode 100644 index 0000000000..a85ab4569f --- /dev/null +++ b/packages/client/ui-plugins/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plugins', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 3eb8fe7eb8..84e471c7fb 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/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/host/README.md -README.md: 926cb0b6b87a8ee76cb2dab745a31f620f4e7f5c -README.zh.md: 7ef057ee56e56ddc2baa7092ccbe44fb161b7448 +README.md: 1c3b6ab3192fe35a5532183414e45d1b02325e57 +README.zh.md: a062d5fce055e3266953993d532a86bec1375377 diff --git a/packages/host/README.md b/packages/host/README.md index 926cb0b6b8..1c3b6ab319 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -13,6 +13,7 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and | [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend | +| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` | `apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 7ef057ee56..a062d5fce0 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -13,6 +13,7 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承 | [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 | +| [`plugin-inventory/`](plugin-inventory/README.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` | `apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器/HTTP 载体。选择器实现可在共享 seam 后互相替换。 diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml new file mode 100644 index 0000000000..761b9c6850 --- /dev/null +++ b/packages/host/plugin-inventory/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/host/plugin-inventory/README.md +README.md: d7a50824d337c66a30ba4332ffb1e36f9ad46547 +README.zh.md: e424b968f1f692fb30b3b0e49efe6c3cd5fadf70 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md new file mode 100644 index 0000000000..d7a50824d3 --- /dev/null +++ b/packages/host/plugin-inventory/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +English | [中文](README.zh.md) + +Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, local display id, effective enablement, and current root Fiber phase. + +The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. + +The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. + +## Model Experience + +None, as this Host-only inventory projection registers no prompt, tool, message, or provider request. + +#### KV Cache effect + +None; this package never assembles model input. + +## Known Limitations and Deferred Work + +- **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. +- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md new file mode 100644 index 0000000000..e424b968f1 --- /dev/null +++ b/packages/host/plugin-inventory/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +[English](README.md) | 中文 + +当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、本地展示 id、有效启用状态与当前根 Fiber 阶段。 + +阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`,TypeRT 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 + +该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 + +## 模型体验 + +无,因为这个仅限 Host 的清单投影不注册提示词、工具、消息或提供方请求。 + +#### KV Cache 影响 + +无;本包从不组装模型输入。 + +## 已知限制与暂缓事项 + +- **仅表示调用当下** —— 结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 +- **无来源与修改能力** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json new file mode 100644 index 0000000000..4da1a61f06 --- /dev/null +++ b/packages/host/plugin-inventory/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-host-plugin-inventory", + "description": "Read-only Remote projection of current Cordis Loader plugin state", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/plugin-inventory" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts new file mode 100644 index 0000000000..8aeafcebed --- /dev/null +++ b/packages/host/plugin-inventory/src/index.ts @@ -0,0 +1,72 @@ +/** Read-only projection of the current Cordis Loader plugin entries. */ + +import type { Context, FiberState } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +// TypeRT-generated ./typert and ./remote artifacts import Zod at runtime. +import type {} from 'zod' +import type { + PluginEntryId, + PluginFiberPhase, + PluginInventoryEntry, + PluginInventorySnapshot, +} from './types.ts' + +export type * from './types.ts' + +/** Brand an existing Loader-tree entry id at the owning boundary. */ +function pluginEntryId(value: string): PluginEntryId { + return value as PluginEntryId +} + +/** Runtime mirror: FiberState is a cross-package const enum. */ +const FIBER_STATE = { + PENDING: 0 as FiberState.PENDING, + LOADING: 1 as FiberState.LOADING, + ACTIVE: 2 as FiberState.ACTIVE, + FAILED: 3 as FiberState.FAILED, + DISPOSED: 4 as FiberState.DISPOSED, + UNLOADING: 5 as FiberState.UNLOADING, +} as const + +/** Complete public projection of Cordis Fiber states. */ +const FIBER_PHASE = { + [FIBER_STATE.PENDING]: 'pending', + [FIBER_STATE.LOADING]: 'loading', + [FIBER_STATE.ACTIVE]: 'active', + [FIBER_STATE.FAILED]: 'failed', + [FIBER_STATE.DISPOSED]: null, + [FIBER_STATE.UNLOADING]: 'unloading', +} as const satisfies Record + +/** Remote-only service exposing the Loader's current non-group entry state. */ +export class PluginInventoryService extends GatewayService { + static inject = ['loader'] + + constructor(ctx: Context) { + super(ctx, 'pluginInventory') + } + + /** + * Read the Loader directly on every call. Cordis's internal plugin/status + * events already maintain Entry.fiber and Fiber.state, so a second cache + * would only add another lifecycle truth to keep synchronized. + * @returns Current non-group Loader entries in Loader order. + */ + @Remote('list') + list(): PluginInventorySnapshot { + const entries: PluginInventoryEntry[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.options.group) continue + entries.push({ + entryId: pluginEntryId(entry.id), + displayId: entry.options.id, + enabled: !entry.disabled, + fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], + }) + } + return { entries } + } +} + +export default PluginInventoryService diff --git a/packages/host/plugin-inventory/src/invariant.ts b/packages/host/plugin-inventory/src/invariant.ts new file mode 100644 index 0000000000..34acc058aa --- /dev/null +++ b/packages/host/plugin-inventory/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-plugin-inventory/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-plugin-inventory' + +/** Cordis companion plugin name. */ +export const name = 'host-plugin-inventory-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: every snapshot is projected directly from Loader-owned state. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts new file mode 100644 index 0000000000..d1c81f5310 --- /dev/null +++ b/packages/host/plugin-inventory/src/types.ts @@ -0,0 +1,28 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable Loader-tree identity of one configured plugin entry. */ +export type PluginEntryId = Branded<'PluginEntryId'> + +/** Lifecycle state of an entry's root Fiber, or null when it has no live root Fiber. */ +export type PluginFiberPhase = + | 'pending' + | 'loading' + | 'active' + | 'failed' + | 'unloading' + | null + +/** One non-group Loader entry exposed to trusted clients. */ +export interface PluginInventoryEntry { + readonly entryId: PluginEntryId + /** Local Loader id used as the compact card title. */ + readonly displayId: string + /** Effective Loader enablement, including disabled ancestor groups. */ + readonly enabled: boolean + readonly fiberPhase: PluginFiberPhase +} + +/** Point-in-time inventory returned by the plugin inventory Remote. */ +export interface PluginInventorySnapshot { + readonly entries: readonly PluginInventoryEntry[] +} diff --git a/packages/host/plugin-inventory/tests/invariant.spec.ts b/packages/host/plugin-inventory/tests/invariant.spec.ts new file mode 100644 index 0000000000..d7e3b99fd8 --- /dev/null +++ b/packages/host/plugin-inventory/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginInventoryInvariant from '../src/invariant.ts' + +describe('plugin-inventory invariant companion', () => { + it('registers the package-owned empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + const fiber = ctx.plugin(PluginInventoryInvariant) + await expect(fiber.await()).resolves.toBeDefined() + await fiber.dispose() + await expect(ctx.plugin(PluginInventoryInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts new file mode 100644 index 0000000000..a822fab2c2 --- /dev/null +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context, type Plugin } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import PluginInventoryService from '../src/index.ts' + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +const activePlugin: Plugin.Function = () => {} +const pendingPlugin: Plugin.Object = { + inject: ['neverReady'], + apply() {}, +} + +async function harness(): Promise<{ + ctx: Context + inventory: PluginInventoryService +}> { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.active = activePlugin + ctx.loader.builtins.pending = pendingPlugin + await ctx.plugin(PluginInventoryService) + const inventory = ctx.get('pluginInventory') as PluginInventoryService + return { ctx, inventory } +} + +describe('PluginInventoryService', () => { + it('publishes one direct list method under the pluginInventory namespace', async () => { + const { inventory } = await harness() + expect(inventory.typertGateway).toMatchObject({ + serviceKey: 'pluginInventory', + namespace: 'pluginInventory', + }) + expect(remoteMethods(inventory)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + ]) + }) + + it('projects current non-group Loader entries without a second cache', async () => { + const { ctx, inventory } = await harness() + const activeId = await ctx.loader.create({ name: 'cordis:active' }) + const pendingId = await ctx.loader.create({ name: 'cordis:pending' }) + const disabledId = await ctx.loader.create({ + name: 'cordis:not-installed', + disabled: true, + }) + await ctx.loader.create({ name: 'cordis:active', group: true }) + + expect(inventory.list()).toEqual({ + entries: [ + { + entryId: activeId, + displayId: activeId, + enabled: true, + fiberPhase: 'active', + }, + { + entryId: pendingId, + displayId: pendingId, + enabled: true, + fiberPhase: 'pending', + }, + { + entryId: disabledId, + displayId: disabledId, + enabled: false, + fiberPhase: null, + }, + ], + }) + + await ctx.loader.update(activeId, { disabled: true }) + expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ + entryId: activeId, + displayId: activeId, + enabled: false, + fiberPhase: null, + }) + + await ctx.loader.remove(pendingId) + expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false) + }) +}) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json new file mode 100644 index 0000000000..524783f8b8 --- /dev/null +++ b/packages/host/plugin-inventory/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e71c12536..9d6c610aaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1593,6 +1596,9 @@ importers: '@deepseek-ai/dsh-client-ui-plugin-config': specifier: workspace:^ version: link:../../client/ui-plugin-config + '@deepseek-ai/dsh-client-ui-plugins': + specifier: workspace:^ + version: link:../../client/ui-plugins '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../client/ui-question @@ -1656,6 +1662,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2582,6 +2591,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-plugins: + 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-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-primitives: dependencies: '@shikijs/langs': @@ -4942,6 +4993,28 @@ importers: specifier: workspace:^ version: link:../../support/invariants + packages/host/plugin-inventory: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index b11a8845e8..f2270444ac 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -91,6 +91,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-plugins': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, @@ -105,6 +106,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers nothing model-facing.' }, 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' }, + 'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' }, 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' }, 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ff8e58e361..b373642dab 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -161,6 +161,8 @@ "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], + "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], + "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], @@ -199,6 +201,7 @@ "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], + "@deepseek-ai/dsh-client-ui-plugins": ["./packages/client/ui-plugins/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes, diff --git a/tsconfig.client.json b/tsconfig.client.json index ce48a77ea9..6def6dddd9 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -81,6 +81,7 @@ { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, { "path": "./packages/client/ui-models" }, + { "path": "./packages/client/ui-plugins" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } diff --git a/tsconfig.host.json b/tsconfig.host.json index d4e7f7ba3b..2f055167a0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -281,6 +281,7 @@ { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, + { "path": "./packages/host/plugin-inventory" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/client" }, { "path": "./packages/sdk/protocol" },