diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index 0633a2745d..9e538f26c6 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -9,8 +9,8 @@ en: dc43ba84345e1405 zh: dbc03572ab7710d2 /agent-note-ship-optional-bundles-with-the-installation/decision: - en: 64a2cb7602842c44 - zh: bfcb3ef4a81773b4 + en: 2f20e356517715a6 + zh: 35c7ff8c4cd8a712 /agent-note-ship-optional-bundles-with-the-installation/alternatives-considered: en: 95b48b61757f1f13 zh: e9fb2d6ea492a4bc diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index a94f480378..74f824fc88 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -10,7 +10,7 @@ The Web plugin page manages only the bundles a person installed into the profile ## Decision -The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/official-bundles/src/bundles.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page presents offered optional bundles in its Official group, tagged experimental when the package is experimental, ahead of the profile's own installed bundles. The [persistent-terminal bundle](../../../../packages/experimental/terminal-bundle/README.md) remains available to the Host but has no page switch, even when enabled; its existing profile selection remains intact. +The launcher names in `OPTIONAL_BUNDLES` (`packages/util/official-bundles/src/bundles.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page presents offered optional bundles in its Official group, tagged experimental when the package is experimental, ahead of the profile's own installed bundles. The [persistent-terminal bundle](../../../../packages/experimental/terminal-bundle/README.md) remains available to the Host but has no page switch, even when enabled; its existing profile selection remains intact. Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index e96a316ccd..129f76c454 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -10,7 +10,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 ## 决策 -启动器在 `OPTIONAL_BUNDLES`(`packages/boot/official-bundles/src/bundles.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页在「官方」分组展示可供选择的可选组合包,属于实验包的带实验性标签,排在 profile 自己已安装的组合包之前。[持久终端组合包](../../../../packages/experimental/terminal-bundle/README.zh.md)仍可供 Host 使用,但即使已启用也不提供页面开关;已有的 profile 选择保持不变。 +启动器在 `OPTIONAL_BUNDLES`(`packages/util/official-bundles/src/bundles.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页在「官方」分组展示可供选择的可选组合包,属于实验包的带实验性标签,排在 profile 自己已安装的组合包之前。[持久终端组合包](../../../../packages/experimental/terminal-bundle/README.zh.md)仍可供 Host 使用,但即使已启用也不提供页面开关;已有的 profile 选择保持不变。 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 254a5a4f73..c52e4793e2 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../packages/boot/app-boot" }, { - "path": "../../packages/boot/official-bundles" + "path": "../../packages/util/official-bundles" }, { "path": "../../packages/boot/cmdline" diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 21cda5fcf8..b0bcc315f0 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -12,7 +12,7 @@ "path": "../../packages/boot/app-boot" }, { - "path": "../../packages/boot/official-bundles" + "path": "../../packages/util/official-bundles" }, { "path": "../../packages/util/home-paths" diff --git a/apps/web/tests/markdown-image-refresh.e2e.ts b/apps/web/tests/markdown-image-refresh.e2e.ts new file mode 100644 index 0000000000..bade81cd1e --- /dev/null +++ b/apps/web/tests/markdown-image-refresh.e2e.ts @@ -0,0 +1,87 @@ +// A rewritten local image file shows its current bytes in a newly rendered Markdown image. +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts' + +const ALT = 'Rewritten chart' +const chart = (width: number, color: string): string => + `` + +/** Build one closed session whose reply shows `{{cwd}}/chart.svg`. */ +function chartFixture(title: string): string { + const session = Session.create(SessionId('markdown-image-refresh-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Draw the chart.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { title, messageSeqs: [user.seq], source: { kind: 'fallback' } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + stream: [], + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: `${title}\n\n![${ALT}]({{cwd}}/chart.svg)` }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const header = { + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, + cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, + } + return [JSON.stringify(header), ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: WEB_FIXTURE_TIME + event.seq * 1_000 })), ''].join('\n') +} + +describe('web e2e: rewritten Markdown image files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await writeFile(join(scaffold.workspaceCwd, 'chart.svg'), chart(120, 'red')) + await seedSession(scaffold, chartFixture('First chart'), 'markdown-image-refresh-first', undefined, + { createdAt: WEB_FIXTURE_TIME - 120_000 }) + await seedSession(scaffold, chartFixture('Second chart'), 'markdown-image-refresh-second', undefined, + { createdAt: WEB_FIXTURE_TIME - 60_000 }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('loads the current file for an image mounted after the file changes', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-image-refresh')) + const width = () => page.getByRole('img', { name: ALT }).evaluate(element => (element as HTMLImageElement).naturalWidth) + await page.getByRole('treeitem').first().click() + // Seeded sessions list newest first under the expanded workspace. + await page.getByRole('treeitem').nth(2).click() + await page.getByText('First chart', { exact: true }).last().waitFor() + await expect.poll(width, { timeout: 10_000 }).toBe(120) + + await writeFile(join(scaffold.workspaceCwd, 'chart.svg'), chart(240, 'blue')) + await page.getByRole('treeitem').nth(1).click() + await page.getByText('Second chart', { exact: true }).last().waitFor() + await expect.poll(width, { timeout: 10_000 }).toBe(240) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 305f54b5f2..7a5f0bd147 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -171,6 +171,7 @@ "tests/fork-mid-turn.e2e.ts", "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", + "tests/markdown-image-refresh.e2e.ts", "tests/markdown-file-links.e2e.ts", "tests/user-text-secret-mask.e2e.ts", "tests/reference-composer.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 635a6bf5bb..58536f99b4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4864,7 +4864,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-llm-mock-server` | — | [`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts) | | `@deepseek-ai/dsh-loader-smoke` | — | [`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts) | | `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) | -| `@deepseek-ai/dsh-official-bundles` | — | [`packages/boot/official-bundles/src/index.ts`](../packages/boot/official-bundles/src/index.ts) | +| `@deepseek-ai/dsh-official-bundles` | — | [`packages/util/official-bundles/src/index.ts`](../packages/util/official-bundles/src/index.ts) | | `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) | | `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) | | `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 05dfab89c0..c7cc4b27f9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4866,7 +4866,7 @@ export interface Config { | `@deepseek-ai/dsh-llm-mock-server` | — | [`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts) | | `@deepseek-ai/dsh-loader-smoke` | — | [`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts) | | `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) | -| `@deepseek-ai/dsh-official-bundles` | — | [`packages/boot/official-bundles/src/index.ts`](../packages/boot/official-bundles/src/index.ts) | +| `@deepseek-ai/dsh-official-bundles` | — | [`packages/util/official-bundles/src/index.ts`](../packages/util/official-bundles/src/index.ts) | | `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) | | `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) | | `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 7308d7df9d..492f4ed711 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -17,6 +17,7 @@ flowchart TD pkg_launch_environment["launch-environment"] pkg_lazy_require["lazy-require"] pkg_native_command["native-command"] + pkg_official_bundles["official-bundles"] pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] pkg_plugin_detail["plugin-detail"] @@ -134,7 +135,6 @@ flowchart TD pkg_cmdline["cmdline"] pkg_config_editor["config-editor"] pkg_hmr["hmr"] - pkg_official_bundles["official-bundles"] pkg_plugin_manager["plugin-manager"] pkg_plugin_market["plugin-market"] end @@ -1467,6 +1467,7 @@ flowchart TD | [`launch-environment`](../packages/util/launch-environment) | `util` | — | | [`lazy-require`](../packages/util/lazy-require) | `util` | — | | [`native-command`](../packages/util/native-command) | `util` | — | +| [`official-bundles`](../packages/util/official-bundles) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | | [`plugin-detail`](../packages/util/plugin-detail) | `util` | — | @@ -1486,7 +1487,6 @@ flowchart TD | [`api-remote-access`](../packages/api/remote-access) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | -| [`official-bundles`](../packages/boot/official-bundles) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ecce1ba225..9c086c9ac7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -19,6 +19,7 @@ flowchart TD pkg_launch_environment["launch-environment"] pkg_lazy_require["lazy-require"] pkg_native_command["native-command"] + pkg_official_bundles["official-bundles"] pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] pkg_plugin_detail["plugin-detail"] @@ -136,7 +137,6 @@ flowchart TD pkg_cmdline["cmdline"] pkg_config_editor["config-editor"] pkg_hmr["hmr"] - pkg_official_bundles["official-bundles"] pkg_plugin_manager["plugin-manager"] pkg_plugin_market["plugin-market"] end @@ -1469,6 +1469,7 @@ flowchart TD | [`launch-environment`](../packages/util/launch-environment) | `util` | — | | [`lazy-require`](../packages/util/lazy-require) | `util` | — | | [`native-command`](../packages/util/native-command) | `util` | — | +| [`official-bundles`](../packages/util/official-bundles) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | | [`plugin-detail`](../packages/util/plugin-detail) | `util` | — | @@ -1488,7 +1489,6 @@ flowchart TD | [`api-remote-access`](../packages/api/remote-access) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | -| [`official-bundles`](../packages/boot/official-bundles) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | diff --git a/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.i18n.yaml index 5697bed51b..e0d389e024 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 0d8984d9bead43c6 zh: 60939a14fcf1ba9a /import-bundle-lists-from-deepseek-ai-dsh-official-bundles/migration: - en: 6db29a6197a38b9e - zh: f1b445be37526806 + en: ef2f3d6258a3b2bb + zh: 65a79e9551f8b237 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.md index 602fa01af5..1316bd299f 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.md @@ -15,4 +15,4 @@ English | [中文](guide.zh.md) 1. Add `@deepseek-ai/dsh-official-bundles` to the `dependencies` of each package that imports one of these names. 2. Change those imports from `@deepseek-ai/dsh-app-boot` to `@deepseek-ai/dsh-official-bundles`, for example `import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'`. Keep importing every other name, such as `initProfile` or `loadProfile`, from `@deepseek-ai/dsh-app-boot`. -3. Run your type check or build; it succeeds when no import of a moved name from `@deepseek-ai/dsh-app-boot` remains. See the [package README](../../../../packages/boot/official-bundles/README.md) for each list. +3. Run your type check or build; it succeeds when no import of a moved name from `@deepseek-ai/dsh-app-boot` remains. See the [package README](../../../../packages/util/official-bundles/README.md) for each list. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.zh.md index bff51e358a..50047b0522 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/official-bundle-lists/guide.zh.md @@ -15,4 +15,4 @@ description: "组合包列表和 profile 模板从 @deepseek-ai/dsh-app-boot 移 1. 在每个导入这些名称的包的 `dependencies` 中加入 `@deepseek-ai/dsh-official-bundles`。 2. 把这些导入从 `@deepseek-ai/dsh-app-boot` 改为 `@deepseek-ai/dsh-official-bundles`,例如 `import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'`。其他名称(如 `initProfile` 或 `loadProfile`)仍从 `@deepseek-ai/dsh-app-boot` 导入。 -3. 运行类型检查或构建;不再有从 `@deepseek-ai/dsh-app-boot` 导入已移动名称的代码时即通过。各列表的说明见[包 README](../../../../packages/boot/official-bundles/README.zh.md)。 +3. 运行类型检查或构建;不再有从 `@deepseek-ai/dsh-app-boot` 导入已移动名称的代码时即通过。各列表的说明见[包 README](../../../../packages/util/official-bundles/README.zh.md)。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 6df1300fd1..55fd8071ea 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -21,8 +21,8 @@ en: 41bdda1ac32df558 zh: aa5d0e325924066b /session-controller/session-media-references: - en: 25e40cb508265c01 - zh: c8ce39a94997af00 + en: 954923654eebe2dc + zh: 6bd9a7d2ea965aed /session-controller/configuration: en: c92590fbaf75e45e zh: fcac67017bb0e3ab diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index abbb52cd63..5e4cfb4419 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -86,7 +86,7 @@ References keep local Session data, scoped Contexts, and history streams alive, ## Session media references -`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 50 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 50 MiB); exceeding this limit returns 413. A request whose `v` parameter differs from the file's current `ctx.fs` version, including a request without one, receives a `private, no-store` 302 to the same route with only `path` and the current `v`; the versioned response is `private, max-age=31536000, immutable`, so browsers reuse cached bytes until the file changes, and private file contents may enter the browser disk cache. Responses contain the complete file, ignore Range, and carry `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel()` saves the first available account model after login when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 58bdbbf48b..ad0de238fb 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -86,7 +86,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 ## 会话媒体引用 -当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 50 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 50 MiB);超过此上限返回 413。`v` 参数与文件当前 `ctx.fs` 版本不同(包括没有该参数)的请求,会收到 `private, no-store` 的 302,跳转到只含 `path` 与当前 `v` 的同一路由;版本化响应为 `private, max-age=31536000, immutable`,因此浏览器在文件变化前复用缓存内容,私有文件内容可能进入浏览器磁盘缓存。响应包含完整文件,忽略 Range,并携带 `nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel()` 在账号登录后、其他提供方均未配置 API key 时,将第一个可用账号模型保存为默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。 diff --git a/packages/api/session-controller/src/media-references.ts b/packages/api/session-controller/src/media-references.ts index 9da80029f5..7effd7a18e 100644 --- a/packages/api/session-controller/src/media-references.ts +++ b/packages/api/session-controller/src/media-references.ts @@ -1,7 +1,9 @@ /** * Authenticated GET/HEAD /api/file reads bounded file responses through * the composed filesystem provider. Paths and MIME types do not restrict access; - * the connection service authenticates requests before this handler. + * the connection service authenticates requests before this handler. A request + * without the current `v` version is redirected, uncached, to the versioned URL, + * whose response is cacheable indefinitely. * @module @deepseek-ai/dsh-api-session-controller/media-references */ @@ -13,33 +15,38 @@ import { FsError, type FileSystem } from '@deepseek-ai/dsh-fs' import mime from 'mime-types' const BASE_HEADERS = { - 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff', // HTML and SVG files may be opened directly on the authenticated API origin. 'Content-Security-Policy': "sandbox; default-src 'none'", } +const NO_STORE = { ...BASE_HEADERS, 'Cache-Control': 'private, no-store' } +// A versioned URL names one file version, so its response never changes. +const VERSIONED = { ...BASE_HEADERS, 'Cache-Control': 'private, max-age=31536000, immutable' } async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise { const fail = (status: number, text: string): Response => - new Response(request.method === 'HEAD' ? null : text, { status, headers: BASE_HEADERS }) - const path = new URL(request.url).searchParams.get('path') + new Response(request.method === 'HEAD' ? null : text, { status, headers: NO_STORE }) + const params = new URL(request.url).searchParams + const path = params.get('path') if (path === null || path.length === 0) return fail(400, 'missing path') if (path.includes('\0') || !isAbsolute(path)) return fail(400, 'absolute path required') try { const target = await fs.resolve(path, { signal: request.signal }) - const mediaType = mime.lookup(target.displayPath) || 'application/octet-stream' + const info = await fs.stat(target, request.signal) + if (info === undefined) return fail(404, 'not found') + if (info.type !== 'file') return fail(403, 'not a regular file') + if (info.size !== undefined && info.size > maxBytes) return fail(413, 'file exceeds byte limit') + if (params.get('v') !== info.version) { + // Relative to the request URL, so deployment prefixes and the Desktop scheme are kept. + const location = `?${new URLSearchParams({ path, v: info.version }).toString()}` + return new Response(null, { status: 302, headers: { ...NO_STORE, Location: location } }) + } const headers: Record = { - ...BASE_HEADERS, - 'Content-Type': mediaType, + ...VERSIONED, + 'Content-Type': mime.lookup(target.displayPath) || 'application/octet-stream', } if (request.method === 'HEAD') { - const info = await fs.stat(target, request.signal) - if (info === undefined) return fail(404, 'not found') - if (info.type !== 'file') return fail(403, 'not a regular file') - if (info.size !== undefined) { - if (info.size > maxBytes) return fail(413, 'file exceeds byte limit') - headers['Content-Length'] = String(info.size) - } + if (info.size !== undefined) headers['Content-Length'] = String(info.size) return new Response(null, { headers }) } const bytes = await fs.readBytes(target, request.signal, maxBytes) diff --git a/packages/api/session-controller/tests/media-references.host.spec.ts b/packages/api/session-controller/tests/media-references.host.spec.ts index 61572ab0be..d25885ed7f 100644 --- a/packages/api/session-controller/tests/media-references.host.spec.ts +++ b/packages/api/session-controller/tests/media-references.host.spec.ts @@ -47,8 +47,15 @@ describe('SessionMediaReferences /api/file', () => { if (handler === undefined) throw new Error('route not registered') return handler(new Request(url, init)) } + const url = (path: string) => `http://127.0.0.1/api/file?path=${encodeURIComponent(path)}` return { - call: (path: string, init?: RequestInit) => raw(`http://127.0.0.1/api/file?path=${encodeURIComponent(path)}`, init), + /** Request a path and follow its version redirect, as an image element does. */ + call: async (path: string, init?: RequestInit) => { + const first = await raw(url(path), init) + const location = first.headers.get('location') + return location === null ? first : raw(new URL(location, url(path)).href, init) + }, + url, raw, fs: ctx.fs as LocalFileSystem, unregister, @@ -105,7 +112,7 @@ describe('SessionMediaReferences /api/file', () => { expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe(mediaType) expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length)) - expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('cache-control')).toBe('private, max-age=31536000, immutable') expect(response.headers.get('x-content-type-options')).toBe('nosniff') expect(response.headers.get('content-security-policy')).toBe("sandbox; default-src 'none'") expect(await responseBytes(response)).toEqual(PNG_BYTES) @@ -121,6 +128,29 @@ describe('SessionMediaReferences /api/file', () => { expect((await route.call(path, { method: 'HEAD' })).status).toBe(413) }) + it('redirects uncached to the current version and serves versioned URLs as immutable', async () => { + const route = await mount() + const path = join(root, 'chart.png') + await writeFile(path, PNG_BYTES) + const first = await route.raw(`${route.url(path)}&load=7`) + expect(first.status).toBe(302) + expect(first.headers.get('cache-control')).toBe('private, no-store') + expect(first.body).toBeNull() + const location = new URL(first.headers.get('location')!, route.url(path)) + expect(location.pathname).toBe('/api/file') + expect([...location.searchParams.keys()]).toEqual(['path', 'v']) + expect(location.searchParams.get('path')).toBe(path) + const versioned = await route.raw(location.href) + expect(versioned.status).toBe(200) + expect(await responseBytes(versioned)).toEqual(PNG_BYTES) + await writeFile(path, new Uint8Array([...PNG_BYTES, 9])) + const stale = await route.raw(location.href) + expect(stale.status).toBe(302) + const next = new URL(stale.headers.get('location')!, route.url(path)) + expect(next.searchParams.get('v')).not.toBe(location.searchParams.get('v')) + expect((await responseBytes(await route.raw(next.href))).at(-1)).toBe(9) + }) + it('ignores Range headers and returns complete bodies without advertising ranges', async () => { const route = await mount() const path = join(root, 'clip.mp4') @@ -192,6 +222,7 @@ describe('SessionMediaReferences /api/file', () => { const route = await mount() const target = { targetKey: FsTargetKey('opaque-remote-id'), displayPath: '/remote/photo.png' } vi.spyOn(route.fs, 'resolve').mockResolvedValue(target) + vi.spyOn(route.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('r1') }) const read = vi.spyOn(route.fs, 'readBytes').mockResolvedValue(PNG_BYTES) expect(await responseBytes(await route.call('/remote/photo.png'))).toEqual(PNG_BYTES) expect(read).toHaveBeenCalledWith(target, expect.any(AbortSignal), DEFAULT_LIMIT) diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 1c4b79c79d..0a33b9fd4f 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.i18n.yaml @@ -15,8 +15,8 @@ en: 09848f4d056e4fc1 zh: 8df6139cff0288f6 /boot-shared-app-bin-boot-glue/packages: - en: c97ae01c92f6756e - zh: 2ebb832078874b7a + en: 358a944a024ffeb1 + zh: f430e25832085068 /boot-shared-app-bin-boot-glue/related-documentation: en: 77af4b40ee7b98a3 zh: e9b4e817823b973d diff --git a/packages/boot/README.md b/packages/boot/README.md index 9ace015e65..04e4325fe3 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -23,7 +23,6 @@ The boot group launches profile applications and manages their installed composi | Package | Role | ctx key | |---|---|---| | [`app-boot`](app-boot/README.md) | Boots a dsh app from a `cordis.yml`: loads `.env`, applies profile and patch layers, and reports startup failures clearly | (library for the bins) | -| [`official-bundles`](official-bundles/README.md) | Lists the bundles the installation ships in profile templates, offers switched off, installs on demand, or retires | (library) | | [`cmdline`](cmdline/README.md) | Lets the app own its flags, `--help`, and exit code; passes everything after the launcher's flags through verbatim | `cmdlineArgs`, `appExit` | | [`hmr`](hmr/README.md) | Coordinates module and configuration reloads with package mutations | `hmr` | | [`config-editor`](config-editor/README.md) | Persists active profile configuration through Loader reconciliation | `configEditor` | diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index aa0c6d3d37..afdee696e2 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -23,7 +23,6 @@ boot 组负责启动 profile 应用并管理其已安装组合。`app-boot` 解 | 包 | 职责 | ctx 键 | |---|---|---| | [`app-boot`](app-boot/README.zh.md) | 从 `cordis.yml` 启动 dsh 应用:加载 `.env`、应用 profile 与 patch 层,并清晰报告启动失败 | (供各 bin 使用的库) | -| [`official-bundles`](official-bundles/README.zh.md) | 列出安装随附于 profile 模板、默认关闭提供、按需安装或已退役的组合包 | (库) | | [`cmdline`](cmdline/README.zh.md) | 让应用持有自己的 flag、`--help` 与退出码;启动器自身 flag 之后的一切原样传入 | `cmdlineArgs`、`appExit` | | [`hmr`](hmr/README.zh.md) | 协调模块与配置重载,并与包修改互斥执行 | `hmr` | | [`config-editor`](config-editor/README.zh.md) | 通过 Loader 应用并持久化当前 profile 配置 | `configEditor` | diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 7170485221..0c16677a8b 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -33,8 +33,8 @@ en: c4a301652b851ce9 zh: 784871e19cd9db0a /deepseek-ai-dsh-app-boot/use-this-package/reading-plugin-display-metadata: - en: b737278c1bc065e7 - zh: f42bdbfe267581d1 + en: 91d80c444ac0cfe5 + zh: e7db496043528ffb /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: en: dc13dfa1f79292e4 zh: d51682ced7a18ac2 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 53c72680be..20204ef7c8 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -86,7 +86,7 @@ The projector preserves native omission behavior by checking literal defaults ag Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. For a package-root specifier, missing locale fields fall back to the accessible `package.json`; a subpath specifier never reads a `package.json`. Malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads an image data URL, even when locale text is complete: a package root uses its manifest `icon`, or `/icon` when that field is omitted; a subpath uses `/icon`. An icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. -The profile templates, installation-owned profile tuples, and retired bundle list that app-boot applies live in [`@deepseek-ai/dsh-official-bundles`](../official-bundles/README.md), together with the installation's default, optional, and on-demand bundle lists. +The profile templates, installation-owned profile tuples, and retired bundle list that app-boot applies live in [`@deepseek-ai/dsh-official-bundles`](../../util/official-bundles/README.md), together with the installation's default, optional, and on-demand bundle lists. ### Startup and reload failures diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a22b7eabab..eb59327df8 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -86,7 +86,7 @@ profile 导入插件前,DSH 会将其声明的 DSH 版本要求与 `getDshRunt 使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。包根标识缺失的 locale 字段回退到可访问的 `package.json`;子路径标识从不读取 `package.json`。格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会加载图片 data URL:包根使用清单 `icon`,省略该字段时使用 `<包名>/icon`;子路径使用 `<标识>/icon`。图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 -app-boot 应用的 profile 模板、安装方拥有的 profile 组合包元组和已退役组合包列表位于 [`@deepseek-ai/dsh-official-bundles`](../official-bundles/README.zh.md),安装方的默认、可选和按需组合包列表也在该包中。 +app-boot 应用的 profile 模板、安装方拥有的 profile 组合包元组和已退役组合包列表位于 [`@deepseek-ai/dsh-official-bundles`](../../util/official-bundles/README.zh.md),安装方的默认、可选和按需组合包列表也在该包中。 ### 启动与重载失败 diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index c1dcee4ba2..b5b42ed67a 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../util/home-paths" }, { - "path": "../official-bundles" + "path": "../../util/official-bundles" }, { "path": "../../util/package-manifest" diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index a9e8b56a25..44d652a408 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -24,7 +24,7 @@ import { setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, getDshRuntimeVersion, ResolutionRestartRequired, } from '@deepseek-ai/dsh-app-boot' -import { OPTIONAL_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG } from '@deepseek-ai/dsh-official-bundles' +import { OPTIONAL_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, PROTECTED_PLUGINS } from '@deepseek-ai/dsh-official-bundles' import type {} from '@deepseek-ai/dsh-hmr' import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' import { bundleManifest, readProfileRegistry, registryArguments, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts' @@ -92,18 +92,6 @@ const DETAIL_FILE_PATH = '/api/plugin-manager/detail-file' /** An http(s) URL, as pnpm's `--registry` takes it. */ const REGISTRY_URL = /^https?:\/\/\S+$/ -const protectedModules = new Set([ - '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/cordis-plugin-loader', - '@deepseek-ai/cordis-plugin-include', '@deepseek-ai/dsh-api-gateway', - '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules', - '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager', - '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', - '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access', - '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools', - '@deepseek-ai/dsh-hmr', -]) - /** The profile files an installation writes and a failed or cancelled one restores. */ const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const @@ -456,7 +444,7 @@ export class PluginManager extends TypertRemoteService { const actual = [...this.ctx.loader.entries()].find(row => row.id === entry.entryId) const candidates = rows.filter(row => row.id === actual?.options.id) const candidate = candidates[0] - if (protectedModules.has(entry.moduleName) || entry.entryId === this.ownerEntryId) { + if (PROTECTED_PLUGINS.has(entry.moduleName) || entry.entryId === this.ownerEntryId) { return { ...entry, readOnlyReason: 'management-required' as const } } if (candidate === undefined || candidates.length > 1 || candidate.name !== entry.moduleName @@ -1167,7 +1155,7 @@ export class PluginManager extends TypertRemoteService { // Unreadable bundles contribute no new rows; listBundles reports their diagnostics. return false } - const protectedBundle = rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId) + const protectedBundle = rows.some(row => PROTECTED_PLUGINS.has(row.name) || `include:${row.id}` === this.ownerEntryId) if (protectedBundle) this.managementBundles.add(name) return protectedBundle } diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json index 02a2169c8e..97732ec2d5 100644 --- a/packages/boot/plugin-manager/tsconfig.json +++ b/packages/boot/plugin-manager/tsconfig.json @@ -24,7 +24,7 @@ "path": "../app-boot" }, { - "path": "../official-bundles" + "path": "../../util/official-bundles" }, { "path": "../../util/atomic-write" diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index c80b06b289..ca7bbc4154 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -82,7 +82,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 16a9ce7a2c..1fe97166b2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -48,8 +48,8 @@ en: 21a0bbe8eb92229c zh: 096ff63db61992bb /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots: - en: b3b8bd3606fc9e97 - zh: 7cd90e182073c8eb + en: 2b34cac9647abe63 + zh: 1929fc24aee6793c /deepseek-ai-dsh-client-ui-plugin-manager/further-exploration: en: f9a42dfed0360d6a zh: 455a70bcb6c69624 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 5a659bc446..bc7802a592 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -142,7 +142,7 @@ While the Plugins page has access, other Client plugins can read `pluginNavigati Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form. -The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins read `pluginNavigation` optionally, through `ctx.get` or `ctx.inject`, and call its `openBundle(packageName)` to open bundle details without changing the current Session; listing it in a plugin's `inject` keeps that plugin pending on a page without access. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [presentation.ts](src/client/presentation.ts); package-name prefixes do not select the marker. +The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins read `pluginNavigation` optionally, through `ctx.get` or `ctx.inject`, and call its `openBundle(packageName)` to open bundle details without changing the current Session; listing it in a plugin's `inject` keeps that plugin pending on a page without access. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [`dsh-official-bundles`](../../util/official-bundles/README.md#plugin-manager-lists); package-name prefixes do not select the marker. `plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index f58f0478c5..5be88a24b2 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -142,7 +142,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。 -页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件经 `ctx.get` 或 `ctx.inject` 以可选方式读取 `pluginNavigation`,调用其 `openBundle(packageName)` 即可打开组合包详情,不改变当前 Session;把它写进插件的 `inject` 会让该插件在无权限的页面上一直等待。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [presentation.ts](src/client/presentation.ts) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。 +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件经 `ctx.get` 或 `ctx.inject` 以可选方式读取 `pluginNavigation`,调用其 `openBundle(packageName)` 即可打开组合包详情,不改变当前 Session;把它写进插件的 `inject` 会让该插件在无权限的页面上一直等待。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [`dsh-official-bundles`](../../util/official-bundles/README.zh.md#plugin-manager-lists) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。 `plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 19f7a646ca..c8637e13ca 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-official-bundles": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", "@testing-library/react": "^16.3.3", "clsx": "^2.1.1", diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 95020799b0..50c0ede3d5 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -21,6 +21,7 @@ import { type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { EXTENSION_BUNDLES, HIDDEN_BUNDLES, MORE_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import type { createNavigationStore } from './navigation-store.ts' import type { PluginHeaderLayoutFace } from './header-scroll.ts' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' @@ -98,40 +99,6 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: { /** How long the list marks a package an install just enabled. */ const HIGHLIGHT_MS = 2_400 -/** - * Built-in profile bundles and persistent terminals have no management page. - * Their Host availability and activation stay intact. - */ -const HIDDEN_BUNDLES = new Set([ - '@deepseek-ai/dsh-base', - '@deepseek-ai/dsh-web-app', - '@deepseek-ai/dsh-headless', - '@deepseek-ai/dsh-sdk-app', - '@deepseek-ai/dsh-acp-app', - '@deepseek-ai/dsh-sdk-minimal', - '@deepseek-ai/dsh-experimental-terminal-bundle', -]) - -/** The optional bundles the main list always holds, in this order; new bundles join the main list. */ -const EXTENSION_BUNDLES = [ - '@deepseek-ai/dsh-experimental-agent-team-profile', - '@deepseek-ai/dsh-experimental-voice-input-bundle', - '@deepseek-ai/dsh-experimental-plugin-market-bundle', - '@deepseek-ai/dsh-experimental-auto-review', -] -/** The optional bundles the main list holds back until selected; the More list includes eligible entries. */ -const MORE_BUNDLES = [ - '@deepseek-ai/dsh-experimental-inspector-profile', - '@deepseek-ai/dsh-cua-bundle', - '@deepseek-ai/dsh-experimental-tool-worktree', - '@deepseek-ai/dsh-experimental-cot-translation-bundle', - '@deepseek-ai/dsh-experimental-session-search', - '@deepseek-ai/dsh-experimental-session-titles-bundle', - '@deepseek-ai/dsh-experimental-badge-skill-bundle', - '@deepseek-ai/dsh-experimental-ralph-bundle', - '@deepseek-ai/dsh-subagent-codex', - '@deepseek-ai/dsh-subagent-claude-code', -] /** * Whether the main list shows an official optional bundle: the always-held ones, and any held-back one the profile enabled. * @param pkg - the official bundle as the Host reports it. diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index c817e84f46..23a95913bf 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -1,5 +1,6 @@ /** Display labels and toast sentences for global plugin management. */ +import { EXPERIMENTAL_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import type { IncompatiblePlugin, ManagementError, Registry, RestartReason } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginManagerLocaleKey } from './locales.ts' @@ -108,22 +109,6 @@ export function shortName(name: string): string { return unscoped.replace(/^dsh-(?:host-|client-)?/, '') } -/** Official bundles that display the Experimental badge. */ -const EXPERIMENTAL_BUNDLES: readonly string[] = [ - '@deepseek-ai/dsh-cua-bundle', - '@deepseek-ai/dsh-experimental-agent-team-profile', - '@deepseek-ai/dsh-experimental-voice-input-bundle', - '@deepseek-ai/dsh-experimental-auto-review', - '@deepseek-ai/dsh-experimental-inspector-profile', - '@deepseek-ai/dsh-experimental-tool-worktree', - '@deepseek-ai/dsh-experimental-cot-translation-bundle', - '@deepseek-ai/dsh-experimental-session-search', - '@deepseek-ai/dsh-experimental-session-titles-bundle', - '@deepseek-ai/dsh-experimental-badge-skill-bundle', - '@deepseek-ai/dsh-experimental-ralph-bundle', - '@deepseek-ai/dsh-experimental-plugin-market-bundle', -] - /** * Resolve installed package metadata without changing its technical identity. * @param pkg - package identity and local metadata. @@ -136,7 +121,7 @@ export function packageText( return { title: pkg.meta?.title === undefined ? pkg.name : resolveText(pkg.meta.title), description: pkg.meta?.description === undefined ? undefined : resolveText(pkg.meta.description) || undefined, - beta: EXPERIMENTAL_BUNDLES.includes(pkg.name), + beta: EXPERIMENTAL_BUNDLES.has(pkg.name), } } diff --git a/packages/client/ui-plugin-manager/tsconfig.client.json b/packages/client/ui-plugin-manager/tsconfig.client.json index 5f8c1f5e42..eefbe67b00 100644 --- a/packages/client/ui-plugin-manager/tsconfig.client.json +++ b/packages/client/ui-plugin-manager/tsconfig.client.json @@ -9,6 +9,9 @@ { "path": "../../util/package-manifest" }, + { + "path": "../../util/official-bundles" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 351d0e9c54..16cef349a8 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -24,8 +24,8 @@ en: b3744373b4b2c1fb zh: 2dbde8ee0bfa5f28 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 02f310acbae29955 - zh: 1e41393d5d714b2d + en: 31ed1a6239427512 + zh: cb489d77de109d3f /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: a943603b74080ef5 zh: 43f7a0188aff271b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index e1b9fd40db..5fbf36b3b1 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -110,7 +110,7 @@ The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. `textInteractionSource` marks its existing root as a source for text selection interactions. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index bf2b60c2af..41d37d6f18 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -110,7 +110,7 @@ kind: "package-library" 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 `textInteractionSource` 将已有根元素标记为文本选区交互的来源。 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx index a139c995b6..f5477bd546 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx @@ -11,7 +11,10 @@ export type MarkdownExternalLinkHandler = (href: string) => void /** Navigation capabilities supplied by the nearest Markdown owner. */ export interface MarkdownDelegate { - /** Image previews for decoded local paths in this owner's workspace. */ + /** + * Image previews for decoded local paths in this owner's workspace. The renderer + * appends a per-mount `load` query parameter to `api/file` route URLs. + */ readonly fileImages?: { fileDrag: import('../file-drag-actions.ts').FileDragActions resolve: (path: string) => string | undefined diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index e2797e0a3f..5da48f6651 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -104,13 +104,38 @@ function vocabularyImageUrl(url: string): string | undefined { * caller. * @param url - The authored markdown destination. * @param pathImages - Rewriting vocabulary, when the render pass has one. + * @param load - The mounted image's load number for rewritten URLs. * @returns The displayable image URL, or undefined. */ -function imageSource(url: string, pathImages: MarkdownPathImages | undefined): string | undefined { +function imageSource(url: string, pathImages: MarkdownPathImages | undefined, load: number): string | undefined { const remote = remoteImageUrl(sanitizeUrl(normalizeUri(url))) if (remote !== undefined) return remote const rewritten = pathImages?.resolve(url) - return rewritten === undefined ? undefined : vocabularyImageUrl(rewritten) + const allowed = rewritten === undefined ? undefined : vocabularyImageUrl(rewritten) + return allowed === undefined ? undefined : freshImageUrl(allowed, load) +} + +// A module counter, not useId: useId derives from tree position, so images at +// the same position in two sessions would share a URL. +let imageLoads = 0 +const nextImageLoad = (): number => ++imageLoads + +/** + * Tag a Host file-route image URL with its mount's load number. Browsers reuse + * a same-URL image within one document despite `no-store`, so without the tag + * a newly rendered image of a rewritten file would show the earlier bytes. + * @param url - Absolute resolved image URL; URLs outside the `api/file` route stay unchanged. + * @param load - Number allocated once per mounted image or file link. + * @returns The URL with a `load` query parameter, or the unchanged URL. + */ +function freshImageUrl(url: string, load: number): string { + let fresh: URL + try { fresh = new URL(url) } + catch { return url } // Only an unparsable owner-supplied URL throws; it is emitted as before. + if (!fresh.pathname.endsWith('/api/file')) return url + // Appending preserves the resolver's existing query encoding. + fresh.search = `${fresh.search === '' ? '?' : `${fresh.search}&`}load=${load}` + return fresh.href } /** Link/image reference targets collected from a document (first definition per identifier wins, as in CommonMark). */ @@ -164,7 +189,8 @@ export interface MarkdownPathImages { * Resolve one authored image destination. * @param value - The destination exactly as the markdown author wrote it. * @returns A displayable absolute URL, or undefined when the destination - * names no displayable image — it then stays inert alt text. + * names no displayable image — it then stays inert alt text. The renderer + * appends a per-mount `load` query parameter to `api/file` route results. */ resolve(value: string): string | undefined } @@ -592,10 +618,12 @@ function MarkdownFileLink({ file, glyph, children }: { readonly children: ReactNode[] }): ReactNode { const { openFile, fileImages } = useMarkdownDelegate() + const [load] = useState(nextImageLoad) if (openFile === undefined) return <>{children} // Pure-image anchors already contain their preview and keep one navigation target. const preview = glyph && classifyLinkPath(file.path) === 'image' ? fileImages : undefined - const src = preview?.resolve(file.path) + const resolved = preview?.resolve(file.path) + const src = resolved === undefined ? undefined : freshImageUrl(resolved, load) const anchor = (