From 0e39055121db9fcd0362d657d2d1dd61530009bb Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 20:49:08 +0800 Subject: [PATCH 01/31] fix(attachment): refuse oversized image sides at admission An image with a side above the deployed routes' 2000px many-image bound could be durably committed by read_image, ride every later request, and permanently fail the session with provider 400s. Admission now enforces a configurable maxImageDimension (default 2000) during the full decode, so read_image surfaces a recoverable tool error naming the limit instead of poisoning durable history; the Web composer gets dedicated copy for the new IMAGE_DIMENSION_TOO_LARGE reason. Fixes #2626 --- ...-image-dimension-admission-limit.i18n.yaml | 6 ++ ...6-08-17-image-dimension-admission-limit.md | 30 +++++++++ ...8-17-image-dimension-admission-limit.zh.md | 30 +++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 2 + docs/subsystems/attachment.zh.md | 2 + examples/acp-agent/tests/acp.snapshot.ts | 11 ++++ .../snapshots/read-image-dimension/input.json | 14 +++++ .../read-image-dimension/session.jsonl | 26 ++++++++ .../stdout.expected.jsonl | 4 ++ .../read-image-dimension/workspace/wide.png | Bin 0 -> 133 bytes packages/acp/acp/tests/harness.ts | 1 + .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/image.ts | 17 +++++- .../attachment/attachment-local/src/index.ts | 12 ++++ .../attachment/attachment-local/src/store.ts | 8 +-- .../attachment-local/tests/image.spec.ts | 9 ++- .../attachment-local/tests/index.spec.ts | 2 + .../attachment-local/tests/store.spec.ts | 4 ++ packages/attachment/attachment/src/error.ts | 1 + packages/attachment/attachment/src/types.ts | 2 + .../attachment/attachment/tests/index.spec.ts | 1 + .../client/connection/src/client/fixture.ts | 1 + .../connection/tests/fixture.client.spec.ts | 1 + .../src/client/image-labels.ts | 3 + .../ui-conversation/src/client/locales.ts | 2 + .../tests/image-labels.client.spec.tsx | 3 + .../tests/input-bar.client.spec.tsx | 4 ++ .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/fs/tool-fs/src/read-image.ts | 18 +++++- packages/fs/tool-fs/tests/read-image.spec.ts | 57 +++++++++++++++++- .../host/apiproxy/src/api/sessions.schema.ts | 1 + .../apiproxy/tests/api-proxy-models.spec.ts | 1 + .../tests/api-proxy-projections.spec.ts | 1 + packages/llm/llm-pi-ai/tests/adapter.spec.ts | 1 + .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 1 + .../mcp/mcp-client/tests/mcp-client.spec.ts | 1 + scripts/gen-tool-catalog.ts | 1 + scripts/test-invariants.ts | 1 + 47 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md create mode 100644 examples/acp-agent/tests/snapshots/read-image-dimension/input.json create mode 100644 examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-dimension/workspace/wide.png diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml new file mode 100644 index 0000000000..d04cbbe0e1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md +2026-08-17-image-dimension-admission-limit.md: 027259c0949d142ce8d8af27e7daa2abd54769ab +2026-08-17-image-dimension-admission-limit.zh.md: 3b66fe9a474f965653f94dacc7e0b8b0d0b9229a diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md new file mode 100644 index 0000000000..027259c094 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md @@ -0,0 +1,30 @@ +# Agent Note: Per-side image dimension admission limit + +Status: implemented + +English | [中文](2026-08-17-image-dimension-admission-limit.zh.md) + +## Problem + +`read_image` durably committed an image and appended its block to session history before any dimension check beyond byte count and total pixels. Deployed model routes reject a request with HTTP 400 when it carries many images and any of them has a side above 2000px. An admitted image rides every later request of its session, so one oversized read poisoned the durable history: the next model request failed, and so did every retry, permanently killing the session. The same gap applied to every other image producer (host uploads, MCP tool images) because admission had no per-side bound at all. + +## Decision + +`ImageAttachmentLimits` carries `maxImageDimension`, enforced during the admission full decode (`detectImage`) as `IMAGE_DIMENSION_TOO_LARGE`, so every producer that commits through the attachment service refuses an oversized image before anything reaches durable history. `LocalAttachmentStore` exposes it as the `maxImageDimension` config field with default `DEFAULT_MAX_IMAGE_DIMENSION = 2000`, the strictest per-side bound deployed routes enforce; deployments with laxer routes raise it from cordis.yml. `read_image` maps `IMAGE_DIMENSION_TOO_LARGE` and `IMAGE_TOO_MANY_PIXELS` to model-facing errors that name the resolved path and the limit and tell the model to downscale and retry — the turn continues as a recoverable tool error. The Web composer surfaces `IMAGE_DIMENSION_TOO_LARGE` with dedicated copy naming the limit. The `read-image-dimension` snapshot scenario replays the refusal keylessly through the assembled app: a 2001x1 workspace fixture, a recoverable tool error, and a completed turn. + +## Alternatives considered + +- **Downscale at admission instead of refusing.** Resampling changes the stored bytes away from what the caller supplied, adds a resampling-quality policy, and hides the limit from the model. Refusal keeps admission a pure gate; the model or user can downscale with full knowledge. Worth revisiting only if refusals prove frequent in practice. +- **Enforce at the provider adapter per route.** Too late: by the time a request is assembled the image is already durable history, so every route and every retry re-fails. Admission is the last point where a provider-rejected image can be kept out. +- **Repair already-poisoned sessions** (drop or replace the oversized block on later requests). Out of scope for this fix; admission prevents new poisonings, and history rewriting needs its own design against the model-visible ⟺ logged invariant. + +## Related + +- [Minimal read_image tool](../feature/2026-08-10-minimal-read-image-tool.md) — the tool whose admission gap this closes. +- [Web image intake and limits alignment](../feature/2026-08-12-web-image-intake-and-limits-alignment.md) — the composer-side surfacing of the same `ImageAttachmentLimits`. + +## Consequences + +- One oversized `read_image` can no longer break a session; the model sees an actionable error and the turn completes. +- Images with a side above 2000px are refused even in compositions whose routes would accept them on small requests; such deployments must raise `maxImageDimension` explicitly. +- Sessions that already carry an oversized image remain broken; this change does not repair existing history. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md new file mode 100644 index 0000000000..3b66fe9a47 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 图片单边尺寸准入上限 + +Status: implemented + +[English](2026-08-17-image-dimension-admission-limit.md) | 中文 + +## Problem + +`read_image` 在字节数与总像素之外没有任何尺寸检查,就把图片持久提交并追加进会话历史。已部署的模型路由在请求携带多张图片且其中任何一张单边超过 2000px 时会以 HTTP 400 拒绝整个请求。已接纳的图片会随该会话之后的每次请求发送,因此一次超限读取就毒化了持久历史:下一次模型请求失败,之后的每次重试同样失败,会话被永久杀死。其他图片来源(宿主上传、MCP 工具图片)存在同样的缺口,因为准入完全没有单边上限。 + +## Decision + +`ImageAttachmentLimits` 增加 `maxImageDimension`,在准入完整解码(`detectImage`)中以 `IMAGE_DIMENSION_TOO_LARGE` 强制执行,因此所有经附件服务提交的来源都会在任何内容进入持久历史之前拒绝超限图片。`LocalAttachmentStore` 将其暴露为 `maxImageDimension` 配置项,默认值 `DEFAULT_MAX_IMAGE_DIMENSION = 2000`,即已部署路由强制执行的最严格单边上限;路由更宽松的部署可在 cordis.yml 中调高。`read_image` 把 `IMAGE_DIMENSION_TOO_LARGE` 与 `IMAGE_TOO_MANY_PIXELS` 映射为面向模型的错误,指明解析后的路径与上限并提示缩图重试,本轮以可恢复的工具错误继续。Web 输入框对 `IMAGE_DIMENSION_TOO_LARGE` 给出指明上限的专用文案。`read-image-dimension` 快照场景通过组装后的应用无 key 回放这次拒绝:2001x1 的工作区 fixture、一条可恢复的工具错误、一个正常完成的轮次。 + +## Alternatives considered + +- **准入时缩图而非拒绝。** 重采样会让存储字节偏离调用方提供的内容,引入重采样质量策略,还会对模型隐藏上限。拒绝让准入保持为纯粹的门禁;模型或用户可以在知情的前提下自行缩图。只有当拒绝在实践中频繁出现时才值得重新考虑。 +- **在 provider 适配器按路由强制执行。** 为时已晚:组装请求时图片已是持久历史,每条路由、每次重试都会再次失败。准入是把必然被上游拒绝的图片挡在外面的最后一道关口。 +- **修复已被毒化的会话**(在之后的请求中丢弃或替换超限图片块)。不在本次修复范围内;准入阻止新的毒化,而重写历史需要针对「模型可见 ⟺ 已记录」不变量单独设计。 + +## Related + +- [最小 read_image 工具](../feature/2026-08-10-minimal-read-image-tool.md),本次修复补上的正是该工具的准入缺口。 +- [Web 图片摄入与限制对齐](../feature/2026-08-12-web-image-intake-and-limits-alignment.md),同一组 `ImageAttachmentLimits` 在输入框侧的呈现。 + +## Consequences + +- 一次超限的 `read_image` 不再能弄坏会话;模型看到可操作的错误,轮次正常完成。 +- 单边超过 2000px 的图片即使在其路由本可接受(小请求)的组合中也会被拒绝;这类部署必须显式调高 `maxImageDimension`。 +- 已经携带超限图片的会话仍然是坏的;本次改动不修复既有历史。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ff77328710..62eadcff60 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: 4da2774eb94eae3216c2cf89b7864a075872f9ee -config-catalog.zh.md: 4f9e2cf3ecb454cbc34da565bdc23877c4b54401 +config-catalog.md: 9f8f307b19910f0a0e61296e74f2f9b4d4ed6bec +config-catalog.zh.md: c7f88416a52f0e106c82fffeceb325b50d9ba068 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4da2774eb9..9f8f307b19 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -335,10 +335,12 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number + /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + maxImageDimension?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:32`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4f9e2cf3ec..c7f88416a5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -337,10 +337,12 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number + /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + maxImageDimension?: number } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:32`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index a70ac6dbd6..023bbafab0 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/attachment.md -attachment.md: ff5a802b23b0111dff4481394772438f5d68feab -attachment.zh.md: e3db8cd58e9bf2eebad66dacb78353cad98d2fd5 +attachment.md: 21e60dbc40504f22229ef98a2dd112eda82fffdd +attachment.zh.md: 886e569b6db9f2a5b1dca39125785d8286e22c7a diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index ff5a802b23..21e60dbc40 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -42,6 +42,8 @@ interface ImageAttachmentLimits { maxImagesPerMessage: number maxMessageImageBytes: number maxImagePixels: number + /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */ + maxImageDimension: number mediaTypes: readonly ImageMediaType[] } ``` diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index e3db8cd58e..886e569b6d 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -42,6 +42,8 @@ interface ImageAttachmentLimits { maxImagesPerMessage: number maxMessageImageBytes: number maxImagePixels: number + /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */ + maxImageDimension: number mediaTypes: readonly ImageMediaType[] } ``` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 173a903e1d..3d40e1f196 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -213,6 +213,17 @@ const SCENARIOS: Scenario[] = [ headerClass: 'image', configPath: IMAGE_TEXT_ROUTE_CONFIG, }, + // Authored keyless replay of the oversized-image refusal: admission rejects + // the 2001x1 fixture at the default 2000px per-side limit, the model sees a + // recoverable tool error, and the turn still completes — the image never + // enters durable history. + { + name: 'read-image-dimension', + hasModelTurn: true, + recorded: false, + headerClass: 'image', + configPath: IMAGE_CONFIG, + }, { name: 'inline-image-prompt', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/input.json b/examples/acp-agent/tests/snapshots/read-image-dimension/input.json new file mode 100644 index 0000000000..43e6299ef8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl new file mode 100644 index 0000000000..0b979e801f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1783951000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}} +{"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1783951000015,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}} +{"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl new file mode 100644 index 0000000000..7dbc881712 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TOOLARGE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/workspace/wide.png b/examples/acp-agent/tests/snapshots/read-image-dimension/workspace/wide.png new file mode 100644 index 0000000000000000000000000000000000000000..cfdedb6b1795acf138c765dbe955d569269294aa GIT binary patch literal 133 zcmeAS@N?(olHy`uVBq!ia0y~yV7~}tGjcEi$!D=AMSv7%fk$L90|WC55N6z39h40e zH1~9I42fucd&7{Ifr01nh7am@8*gN5a?~uF$|0cO(7?dR#Dd0X+}r+>F^B);%nx^_ RGy{!b@O1TaS?83{1OPqKBk=$L literal 0 HcmV?d00001 diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index ae66f9f841..ce6e93794f 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -82,6 +82,7 @@ const IMAGE_LIMITS: ImageAttachmentLimits = { maxImagesPerMessage: 4, maxMessageImageBytes: 2048, maxImagePixels: 1024, + maxImageDimension: 2000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index d875ce6519..1d7c63c469 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119 -README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3 +README.md: e4f2d5748768a1dc2a6b79c3ed9e364c56a67248 +README.zh.md: 6b548fb993faef996f1508ba9f9efc31b20fea64 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index ba0b9efb2c..e4f2d57487 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte, total-pixel, and per-side dimension limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. The per-side default (2000px) stays below the strictest dimension bound deployed model routes enforce on requests carrying many images: an admitted image rides every later request of its session, so admission is the last point where a provider-rejected image can be kept out of durable history. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 8e2474357a..6b548fb993 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节、总像素和单边尺寸限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。单边默认值(2000px)低于已部署模型路由对携带多张图片的请求所强制执行的最严格尺寸上限:一张已接纳的图片会随会话之后的每次请求发送,准入是把必然被上游拒绝的图片挡在持久历史之外的最后一道关口。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index e06bf459df..b067ea80ff 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -44,19 +44,30 @@ export async function probeImage(data: Uint8Array): Promise { } } +/** Admission limits applied to a decoded raster's intrinsic dimensions. */ +export interface DecodedImageLimits { + /** Decoded-pixel (width times height) admission limit. */ + maxPixels?: number + /** Per-side admission limit applied to width and height independently. */ + maxDimension?: number +} + /** * Fully decode a supported raster and return its intrinsic metadata. * @param data - complete encoded image bytes. - * @param maxPixels - decoded-pixel admission limit. + * @param limits - intrinsic-dimension admission limits. * @returns verified format and dimensions. */ -export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { +export async function detectImage(data: Uint8Array, limits?: DecodedImageLimits): Promise { try { const image = sharp(data, { failOn: 'error', limitInputPixels: false }) const detected = await imageMetadata(image) - if (maxPixels !== undefined && detected.width * detected.height > maxPixels) { + if (limits?.maxPixels !== undefined && detected.width * detected.height > limits.maxPixels) { throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') } + if (limits?.maxDimension !== undefined && Math.max(detected.width, detected.height) > limits.maxDimension) { + throw new AttachmentError('Image exceeds the configured per-side pixel limit.', 'IMAGE_DIMENSION_TOO_LARGE') + } await image.raw().toBuffer() return detected } catch (error) { diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 98268895f6..71836705f7 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -19,6 +19,14 @@ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024 /** Default maximum intrinsic pixels for one image. */ export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 +/** + * Default maximum intrinsic width and height for one image. Deployed model + * routes reject any request whose history carries an image with a side above + * 2000px once the request holds many images, and an admitted image rides + * every later request of its session, so admission refuses at the same line + * to keep the durable history streamable. + */ +export const DEFAULT_MAX_IMAGE_DIMENSION = 2000 /** Local attachment backend configuration. */ export interface Config { @@ -32,6 +40,8 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number + /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + maxImageDimension?: number } /** Persistent content-addressed local attachment store. */ @@ -42,6 +52,7 @@ export class LocalAttachmentStore extends AttachmentStore { maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE), maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), + maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), }) /** Absolute versioned storage root. */ @@ -56,6 +67,7 @@ export class LocalAttachmentStore extends AttachmentStore { maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS, + maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 8e4e83c1c9..723df98720 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -46,10 +46,10 @@ function ensureReference(ref: ImageAttachmentRef): string { async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], - maxPixels?: number, + limits: ImageAttachmentLimits, ): Promise> { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') - const detected = await detectImage(data, maxPixels) + const detected = await detectImage(data, { maxPixels: limits.maxImagePixels, maxDimension: limits.maxImageDimension }) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') return { ...detected, bytes: data.byteLength } } @@ -64,7 +64,7 @@ export async function validateImageFile(input: SaveImageAttachment, limits: Imag if (input.data.byteLength > limits.maxImageBytes) { throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') } - await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) + await inspectMetadata(input.data, input.mediaType, limits) } /** @@ -135,7 +135,7 @@ async function ensureDurableHome(path: string): Promise { */ export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') - const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) + const metadata = await inspectMetadata(input.data, input.mediaType, limits) const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts index 72a258e2bd..6b1cea6bfb 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -23,10 +23,17 @@ describe('raster decoding', () => { }) it('rejects excess decoded pixels before decoding', async () => { - await expect(detectImage(await raster('png'), 5)) + await expect(detectImage(await raster('png'), { maxPixels: 5 })) .rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) }) + it('rejects a side above the per-side limit and accepts a side exactly at it', async () => { + await expect(detectImage(await raster('png'), { maxDimension: 2 })) + .rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) + await expect(detectImage(await raster('png'), { maxDimension: 3 })) + .resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2 }) + }) + it('rejects malformed bytes and truncated payloads with readable headers', async () => { await expect(detectImage(Uint8Array.of(1, 2, 3))) .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 75629d3635..859008c7d7 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import LocalAttachmentStore, { DEFAULT_MAX_IMAGE_BYTES, + DEFAULT_MAX_IMAGE_DIMENSION, DEFAULT_MAX_IMAGE_PIXELS, DEFAULT_MAX_IMAGES_PER_MESSAGE, DEFAULT_MAX_MESSAGE_IMAGE_BYTES, @@ -20,6 +21,7 @@ describe('local attachment service', () => { maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS, + maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index ec3551abb2..a5b831e933 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -43,6 +43,7 @@ const LIMITS: ImageAttachmentLimits = { maxImagesPerMessage: 2, maxMessageImageBytes: 2048, maxImagePixels: 16, + maxImageDimension: 2000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } @@ -176,6 +177,9 @@ describe('local attachment store', () => { await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + await expect(saveImageFile(storageRoot, { + data: wide, mediaType: 'image/png', + }, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 })).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) const unnamed = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', name: '\u0000', }, LIMITS) diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 125d31ad13..2e2d695dae 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -9,6 +9,7 @@ const IMAGE_ADMISSION_ERROR_CODES = [ 'IMAGE_TYPE_MISMATCH', 'IMAGE_TOO_LARGE', 'IMAGE_TOO_MANY_PIXELS', + 'IMAGE_DIMENSION_TOO_LARGE', ] as const /** Caller-correctable attachment failure codes raised while admitting image input. */ diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 102209553b..62ff598fee 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -29,6 +29,8 @@ export interface ImageAttachmentLimits { maxImagesPerMessage: number maxMessageImageBytes: number maxImagePixels: number + /** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */ + maxImageDimension: number mediaTypes: readonly ImageMediaType[] } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 61caacda0f..622b797ce2 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -15,6 +15,7 @@ const LIMITS = { maxImagesPerMessage: 2, maxMessageImageBytes: 5, maxImagePixels: 4, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dd0566486e..676fd2579a 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1077,6 +1077,7 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record { maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }, } }, diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index bed6f5c89a..439215a91f 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -38,6 +38,9 @@ export function attachmentErrorText( case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported') case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported') case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels') + case 'IMAGE_DIMENSION_TOO_LARGE': + if (limits !== undefined) return t('image.dimensionTooLarge', { size: limits.maxImageDimension }) + break // Undecodable bytes or a declared type its bytes contradict: solvable by // replacing or re-exporting the file, so it reads as a format problem. case 'INVALID_IMAGE': diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c9b6f658ca..368475c583 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -46,6 +46,7 @@ export const zh = { 'image.fileTooLarge': '单张图片不能超过 {size}', 'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片', 'image.tooManyPixels': '图片分辨率过大,请压缩后重试', + 'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试', 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', 'image.subagentUnsupported': '子智能体会话暂不支持图片', 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', @@ -215,6 +216,7 @@ export const en = { 'image.fileTooLarge': 'Each image must be smaller than {size}', 'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again', 'image.tooManyPixels': 'Image resolution is too high; compress it and try again', + 'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again', 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', 'image.subagentUnsupported': 'Subagent sessions do not support images yet', 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx index bec1fa8ebe..bd3b445b6d 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx @@ -32,6 +32,7 @@ describe('attachment rejection copy', () => { maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, } @@ -49,6 +50,7 @@ describe('attachment rejection copy', () => { expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片') expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB') expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片') + expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE', limits)).toBe('图片宽高不能超过 2000px,请缩小后重试') expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images') }) @@ -57,6 +59,7 @@ describe('attachment rejection copy', () => { expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试') expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试') expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE')).toBe('图片发送失败(IMAGE_DIMENSION_TOO_LARGE),请重新添加图片后再试') }) }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f7d5e02a7f..ea8a753738 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -62,6 +62,7 @@ interface BenchOptions { maxImagesPerMessage: number maxMessageImageBytes: number maxImagePixels: number + maxImageDimension: number mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[] } draft?: string @@ -263,6 +264,7 @@ describe('image draft rail', () => { maxImagesPerMessage: 2, maxMessageImageBytes: 2 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, } const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' }) @@ -306,6 +308,7 @@ describe('image draft rail', () => { maxImagesPerMessage: 1, maxMessageImageBytes: 8, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, }, }) @@ -327,6 +330,7 @@ describe('image draft rail', () => { maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, }, }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c029511681..3e94ec5fdb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3137,7 +3137,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageAttachmentLimits', - declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n mediaTypes: readonly ImageMediaType[];\n}', + declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n maxImageDimension: number;\n mediaTypes: readonly ImageMediaType[];\n}', }, { name: 'ImageAttachmentRef', diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 88f33c5c3b..b8cbc626de 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: 71dc4134feee6bf1b5d17eef5f62e587653adf6d -README.zh.md: dff4b838aea4c6fa50a92ce2102bfdd2a96150a5 +README.md: ce7c0ea9070e30c1e6b538933ff5c4605b8d59cc +README.zh.md: 28360fbe6d0ab0f1a2b5ff11e44155ad6468274c diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 71dc4134fe..ce7c0ea907 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -131,7 +131,7 @@ A successful `read_image` returns ``, `image { maxImagesPerMessage: 1, maxMessageImageBytes: 1024, maxImagePixels: 100, + maxImageDimension: 2000, mediaTypes: Object.freeze(['image/jpeg'] as const), }) @@ -390,6 +391,57 @@ describe('image admission failures', () => { const ctx = await setup({ storeConfig: { maxImagePixels: 4 } }) const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model')) expect(result.isError).toBe(true) + expect(text(result)).toContain('exceeds the 4-pixel decoded-size limit') + expect(text(result)).toContain('downscale the image and read the smaller copy') + }) + + it('refuses a side above the per-side limit before anything enters durable history', async () => { + await writeFile(join(dir, 'wide.png'), PNG_3X3) + const ctx = await setup({ storeConfig: { maxImageDimension: 2 } }) + const result = await readImage(ctx, { file_path: 'wide.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('at least one image side exceeds the 2px limit') + expect(text(result)).toContain('downscale the image and read the smaller copy') + }) + + it('passes storage faults and non-attachment failures through unchanged', async () => { + /** Store whose commit fails with a configurable error; admission itself passes. */ + class FailingStore extends AttachmentStore { + static failure: unknown + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/png'] as const), + }) + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(FailingStore.failure) + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + await ctx.plugin(FailingStore) + + FailingStore.failure = new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED') + const storageFault = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(storageFault.isError).toBe(true) + expect(text(storageFault)).toContain('Unable to persist image attachment.') + + FailingStore.failure = new Error('unrelated infrastructure failure') + const unrelated = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(unrelated.isError).toBe(true) + expect(text(unrelated)).toContain('unrelated infrastructure failure') }) it('reports a missing image file and a directory target through the fs vocabulary', async () => { @@ -415,6 +467,7 @@ describe('image admission failures', () => { maxImagesPerMessage: 1, maxMessageImageBytes: 1024, maxImagePixels: 100, + maxImageDimension: 2000, mediaTypes: Object.freeze(['image/png'] as const), }) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 0fbe06bf11..c415015776 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -231,6 +231,7 @@ export const imageLimitsProjectionSchema = z.object({ maxImagesPerMessage: z.number().int().positive(), maxMessageImageBytes: z.number().int().positive(), maxImagePixels: z.number().int().positive(), + maxImageDimension: z.number().int().positive(), mediaTypes: z.array(z.string()), }) as unknown as z.ZodType diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 94f02fcdfb..d353ad0e62 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -147,6 +147,7 @@ describe('Web session model selection', () => { maxImagesPerMessage: 2, maxMessageImageBytes: 4, maxImagePixels: 4, + maxImageDimension: 2000, mediaTypes: ['image/png'], }, validateImage, diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index bef2dba6a2..0fb88c766d 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -94,6 +94,7 @@ describe('session.history projections block', () => { maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, + maxImageDimension: 2000, mediaTypes: ['image/png'] as const, } await ctx.plugin(class extends AttachmentStore { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 51ce347e9a..8a99887b56 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -217,6 +217,7 @@ describe('PiAiAdapter provider routing', () => { maxImagesPerMessage: 1, maxMessageImageBytes: 1, maxImagePixels: 1, + maxImageDimension: 2000, mediaTypes: ['image/png'], } diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index a2a583abd8..e4c15bb34e 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -70,6 +70,7 @@ async function harness(image?: StoredImageAttachment): Promise { maxImagesPerMessage: 1, maxMessageImageBytes: fixture.data.byteLength, maxImagePixels: fixture.ref.width * fixture.ref.height, + maxImageDimension: Math.max(fixture.ref.width, fixture.ref.height), mediaTypes: [fixture.ref.mediaType], } diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 8a155b2f54..164c230c56 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -73,6 +73,7 @@ const IMAGE_LIMITS: ImageAttachmentLimits = { maxImagesPerMessage: 4, maxMessageImageBytes: 2048, maxImagePixels: 1024, + maxImageDimension: 2000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 005eb4aef9..66b8f17eaa 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -72,6 +72,7 @@ class CatalogAttachmentStore extends AttachmentStore { maxImagesPerMessage: 1, maxMessageImageBytes: 1, maxImagePixels: 1, + maxImageDimension: 1, mediaTypes: Object.freeze(['image/png'] as const), }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a9f4cf335b..a3b96a90a3 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -117,6 +117,7 @@ class TestAttachmentStore extends AttachmentStore { maxImagesPerMessage: 1, maxMessageImageBytes: 1, maxImagePixels: 1, + maxImageDimension: 1, mediaTypes: ['image/png'], } From 9705290fe49cd63dc08ced0d66e0fcde97c4fb99 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:42:26 +0800 Subject: [PATCH 02/31] fix: dep version --- packages/code-runtime/code-runtime-python/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 2b7734dc94..7cea7a25b5 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, From bace78045872a3483df83beb4b911b4afe927c93 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:03:57 +0800 Subject: [PATCH 03/31] fix: windows ci --- .../agent-instructions/tests/agent-instructions.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 2bdee49988..171f7322f0 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -1,5 +1,5 @@ import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -80,7 +80,8 @@ class RecordingFileSystem extends FileSystem { override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` } override contains(parent: FsTarget, child: FsTarget): boolean { - return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`) + const descendant = relative(String(parent.targetKey), String(child.targetKey)) + return descendant === '' || (!descendant.startsWith('..') && !isAbsolute(descendant)) } override async stat(target: FsTarget, signal?: AbortSignal): Promise { From 56dff07c4e0bc769eba9e02954c9958459f20332 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:47:32 +0800 Subject: [PATCH 04/31] refactor(client): move schema handling into ui-settings --- .../client/locale/tests/apply.client.spec.ts | 4 +- packages/client/schema-form/README.i18n.yaml | 6 - packages/client/schema-form/README.md | 23 --- packages/client/schema-form/README.zh.md | 23 --- packages/client/schema-form/package.json | 45 ------ packages/client/schema-form/src/index.ts | 12 -- packages/client/schema-form/src/invariant.ts | 32 ---- packages/client/schema-form/src/model.ts | 151 ------------------ .../tests/invariant.client.spec.ts | 12 -- .../schema-form/tests/model.client.spec.ts | 100 ------------ packages/client/schema-form/tsconfig.json | 18 --- packages/client/schema-form/tsdown.config.ts | 6 - .../client/ui-permission-presets/package.json | 7 +- .../ui-permission-presets/src/client/index.ts | 4 +- .../src/client/settings-store.ts | 15 +- .../permission-presets-row.client.spec.tsx | 18 ++- .../tests/settings-store.client.spec.ts | 48 +++--- .../ui-permission-presets/tsconfig.json | 3 - .../client/ui-settings-models/package.json | 8 +- .../src/client/DeepSeekOnboardingDialog.tsx | 1 + .../src/client/ModelsSection.tsx | 7 +- .../src/client/ProviderEditor.tsx | 75 +++++---- .../ui-settings-models/src/client/index.ts | 4 +- .../ui-settings-models/src/client/store.ts | 30 ++-- .../tests/components.client.spec.tsx | 18 ++- .../tests/onboarding-dialog.client.spec.tsx | 3 +- .../tests/provider-form.client.spec.tsx | 11 +- .../tests/settings-schema.client.ts | 5 + .../tests/store.client.spec.ts | 25 +-- .../client/ui-settings-models/tsconfig.json | 3 - .../tests/apply.client.spec.ts | 4 +- packages/client/ui-settings/package.json | 12 +- .../client/ui-settings/src/client/index.ts | 6 +- .../client/ui-settings/src/client/schema.ts | 121 ++++++++++++++ .../ui-settings/src/client/settings-scope.ts | 9 +- .../tests/settings-scope.client.spec.ts | 5 +- packages/client/ui-settings/tsconfig.json | 2 +- .../ui-theme/tests/apply.client.spec.ts | 4 +- 38 files changed, 312 insertions(+), 568 deletions(-) delete mode 100644 packages/client/schema-form/README.i18n.yaml delete mode 100644 packages/client/schema-form/README.md delete mode 100644 packages/client/schema-form/README.zh.md delete mode 100644 packages/client/schema-form/package.json delete mode 100644 packages/client/schema-form/src/index.ts delete mode 100644 packages/client/schema-form/src/invariant.ts delete mode 100644 packages/client/schema-form/src/model.ts delete mode 100644 packages/client/schema-form/tests/invariant.client.spec.ts delete mode 100644 packages/client/schema-form/tests/model.client.spec.ts delete mode 100644 packages/client/schema-form/tsconfig.json delete mode 100644 packages/client/schema-form/tsdown.config.ts create mode 100644 packages/client/ui-settings-models/tests/settings-schema.client.ts create mode 100644 packages/client/ui-settings/src/client/schema.ts diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index dd38786073..c54644275c 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SETTINGS_NS, @@ -47,7 +47,7 @@ async function bench() { ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, diff --git a/packages/client/schema-form/README.i18n.yaml b/packages/client/schema-form/README.i18n.yaml deleted file mode 100644 index e0d2db8a38..0000000000 --- a/packages/client/schema-form/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/schema-form/README.md -README.md: ef1d2f9d8ce936fe60d38849f975dc8c0a08ded4 -README.zh.md: 315e508ab1a2837acf4d159795cdcc93dedd72fa diff --git a/packages/client/schema-form/README.md b/packages/client/schema-form/README.md deleted file mode 100644 index ef1d2f9d8c..0000000000 --- a/packages/client/schema-form/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# @deepseek-ai/dsh-client-schema-form - -English | [中文](README.zh.md) - -Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the Service Definition's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering. - -## Contract - -The unit of editing is a **draft user section**: a plain object edited immutably (`setPath` materializes intermediates, `deletePath` is the per-field reset — dropping the key falls the resolved value back to the composition base and schema defaults). A field's presence in the draft marks it **overridden** (`hasPath`) — presence semantics, not value comparison, exactly mirroring the settings seam's layering. `nodeAtPath` resolves the schema node addressed by a configurable-provider directory `settingsPath` (object properties by name, dict entries through `inner`), so an editor can probe which fields a provider's profile carries (and their `meta.role`) before deciding what to render; an unresolvable path returns `undefined` so the caller degrades loudly instead of rendering a wrong subtree. `validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages reject an invalid draft before writing. - -## Model Experience - -None, as this package backs browser configuration editors; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. This is safe only for an envelope from the same trusted host that serves the page; the protocol provides no inert cross-trust representation. -- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message, including its `$.path`; it does not map errors onto individual controls. -- **No generic renderer** — consumers build feature-specific forms over these helpers. The [Web config-plane Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) records that trade-off. diff --git a/packages/client/schema-form/README.zh.md b/packages/client/schema-form/README.zh.md deleted file mode 100644 index 315e508ab1..0000000000 --- a/packages/client/schema-form/README.zh.md +++ /dev/null @@ -1,23 +0,0 @@ -# @deepseek-ai/dsh-client-schema-form - -[English](README.md) | 中文 - -面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 封装);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 Service Definition 的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React,也不做任何渲染。 - -## 约定 - -编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**(`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点(object 属性按名称解析,dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会明确进入降级路径,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿。 - -## 模型体验 - -无。该包支撑的是浏览器配置编辑器;这里没有任何内容进入模型请求。 - -#### KV Cache 影响 - -无;该包既不组装也不发送提供方请求。 - -## 已知限制与暂缓事项 - -- **重建 schema 会执行所收到的封装**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的回调函数,因此 schema 信封是可执行内容,而不是不可执行数据。只有该封装来自提供该页面的同一受信任宿主时才安全;该协议没有跨信任边界使用的不可执行表示。 -- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件。 -- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) 记录该权衡。 diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json deleted file mode 100644 index 4951dee5c5..0000000000 --- a/packages/client/schema-form/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-schema-form", - "description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path", - "version": "0.1.0-rc.7", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/schema-form" - }, - "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" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "license": "MIT", - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts" - ] -} diff --git a/packages/client/schema-form/src/index.ts b/packages/client/schema-form/src/index.ts deleted file mode 100644 index 3a8c35edcb..0000000000 --- a/packages/client/schema-form/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Schema/draft model layer for settings editors: rehydrate the wire's - * serialized schemastery envelope, resolve nodes by settings path, validate - * drafts, and edit them immutably by path. Editors render their own controls - * (the Models page hand-writes its layout) on top of these helpers. - * @module @deepseek-ai/dsh-client-schema-form - */ - -export { - deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, -} from './model.ts' -export type { SchemaNode } from './model.ts' diff --git a/packages/client/schema-form/src/invariant.ts b/packages/client/schema-form/src/invariant.ts deleted file mode 100644 index 90636e5d67..0000000000 --- a/packages/client/schema-form/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-schema-form`. - * @module @deepseek-ai/dsh-client-schema-form/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-schema-form' - -/** Cordis companion plugin name. */ -export const name = 'client-schema-form-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a pure schema/draft helper library — it emits no - * cordis events and owns no cross-plugin mutable relation; draft - * immutability, schema rehydration, and path-edit round trips are asserted - * directly by this package's model specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/schema-form/src/model.ts b/packages/client/schema-form/src/model.ts deleted file mode 100644 index 5cfb624eb4..0000000000 --- a/packages/client/schema-form/src/model.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Schema introspection and draft-editing helpers behind settings editors. - * The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a - * live validator whose node relations (`dict`/`inner`) editors probe for - * field presence and roles; drafts are edited immutably by path. - * @module @deepseek-ai/dsh-client-schema-form/model - */ - -import Schema from '@deepseek-ai/schemastery' - -/** Live schemastery node; the renderer reads only its structural relations. */ -export type SchemaNode = Schema - -/** - * Rehydrate a serialized schema envelope into a live validator/node tree. - * @param serialized - `schema.toJSON()` output received over the wire. - * @returns the root schema node. - */ -export function rehydrateSchema(serialized: unknown): SchemaNode { - return new Schema(serialized as Schema) -} - -/** - * Validate a draft against a rehydrated schema. - * @param schema - rehydrated root node. - * @param draft - candidate value. - * @returns the validation failure message, or `undefined` when the draft passes. - */ -export function validateDraft(schema: SchemaNode, draft: unknown): string | undefined { - try { - ;(schema as unknown as (value: unknown) => unknown)(draft) - return undefined - } catch (error) { - return error instanceof Error ? error.message : String(error) - } -} - -/** - * Resolve the schema node at a settings path (the configurable-provider - * directory's `settingsPath` vocabulary): object properties by name, dict - * entries through `inner`. An unresolvable segment returns `undefined` so - * the caller falls back instead of rendering a wrong subtree. - * @param root - rehydrated section root node. - * @param path - key path from the section root. - * @returns the node describing that position, or `undefined`. - */ -export function nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined { - let node: SchemaNode | undefined = root - for (const key of path) { - if (node === undefined) return undefined - if (node.type === 'object') node = (node.dict as Record | undefined)?.[key] - else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined - else return undefined - } - return node -} - -/** - * Read a nested value by path. - * @param value - root value (draft or fallback layer). - * @param path - key path from the root; array indexes as strings. - * @returns the value at the path, or `undefined` along a missing branch. - */ -export function getPath(value: unknown, path: readonly string[]): unknown { - let current: unknown = value - for (const key of path) { - if (Array.isArray(current)) { - current = current[Number(key)] - continue - } - if (typeof current !== 'object' || current === null) return undefined - current = (current as Record)[key] - } - return current -} - -/** - * Whether a draft explicitly carries the path (its presence marks a user - * override, independent of the value stored there). - * @param value - root value (draft or fallback layer). - * @param path - key path from the root; array indexes as strings. - * @returns whether the path's final key exists on its parent. - */ -export function hasPath(value: unknown, path: readonly string[]): boolean { - if (path.length === 0) return value !== undefined - const parent = getPath(value, path.slice(0, -1)) - const key = path[path.length - 1] as string - if (Array.isArray(parent)) return Number(key) < parent.length - if (typeof parent !== 'object' || parent === null) return false - return key in parent -} - -function cloneContainer(container: unknown, key: string): Record | unknown[] { - if (Array.isArray(container)) return [...container as unknown[]] - if (typeof container === 'object' && container !== null) return { ...container as Record } - // A missing intermediate materializes as the container the next key needs. - return /^\d+$/.test(key) ? [] : {} -} - -/** Clone the container spine down to the leaf's parent, materializing missing intermediates. */ -function cloneSpine(root: Record, path: readonly string[]): { - result: Record - parent: Record | unknown[] - leaf: string -} { - const result = { ...root } - let target: Record | unknown[] = result - for (let i = 0; i < path.length - 1; i++) { - const key = path[i] as string - const child = cloneContainer( - Array.isArray(target) ? target[Number(key)] : (target)[key], - path[i + 1] as string, - ) - if (Array.isArray(target)) target[Number(key)] = child - else (target)[key] = child - target = child - } - return { result, parent: target, leaf: path[path.length - 1] as string } -} - -/** - * Immutably set a nested value, materializing missing intermediate containers. - * @param root - draft root (never mutated). - * @param path - non-empty key path. - * @param value - value to store at the path. - * @returns the new draft root. - */ -export function setPath(root: Record, path: readonly string[], value: unknown): Record { - if (path.length === 0) throw new Error('schema-form: setPath needs a non-empty path') - const { result, parent, leaf } = cloneSpine(root, path) - if (Array.isArray(parent)) parent[Number(leaf)] = value - else parent[leaf] = value - return result -} - -/** - * Immutably remove a nested key (the per-field reset: the resolved value - * falls back to the composition base and schema defaults). Removing along a - * missing branch returns the root unchanged. - * @param root - draft root (never mutated). - * @param path - non-empty key path. - * @returns the new draft root. - */ -export function deletePath(root: Record, path: readonly string[]): Record { - if (path.length === 0) throw new Error('schema-form: deletePath needs a non-empty path') - if (!hasPath(root, path)) return root - const { result, parent, leaf } = cloneSpine(root, path) - if (Array.isArray(parent)) parent.splice(Number(leaf), 1) - else Reflect.deleteProperty(parent, leaf) - return result -} diff --git a/packages/client/schema-form/tests/invariant.client.spec.ts b/packages/client/schema-form/tests/invariant.client.spec.ts deleted file mode 100644 index 6e63f8f995..0000000000 --- a/packages/client/schema-form/tests/invariant.client.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(SchemaFormInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/client/schema-form/tests/model.client.spec.ts b/packages/client/schema-form/tests/model.client.spec.ts deleted file mode 100644 index 1a95e88903..0000000000 --- a/packages/client/schema-form/tests/model.client.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest' -import Schema from '@deepseek-ai/schemastery' -import { - deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, -} from '../src/model.ts' - -const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON())) - -describe('rehydration and validation', () => { - it('rehydrates a serialized envelope into a working validator', () => { - const root = rehydrateSchema(Wire(Schema.object({ name: Schema.string().required() }))) - expect(validateDraft(root, { name: 'ok' })).toBeUndefined() - expect(validateDraft(root, { name: 42 })).toContain('name') - }) - - it('stringifies non-Error validation throws', () => { - const hostile = (() => { - throw 'plain-string failure' - }) as unknown as Parameters[0] - expect(validateDraft(hostile, {})).toBe('plain-string failure') - }) -}) - -describe('path helpers', () => { - const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] } - - it('reads nested object and array paths', () => { - expect(getPath(root, [])).toBe(root) - expect(getPath(root, ['providers', 'openai', 'baseURL'])).toBe('https://x') - expect(getPath(root, ['models', '0', 'id'])).toBe('a') - expect(getPath(root, ['providers', 'missing', 'x'])).toBeUndefined() - expect(getPath(root, ['providers', 'openai', 'baseURL', 'deep'])).toBeUndefined() - }) - - it('reports draft presence by key existence, not value truthiness', () => { - expect(hasPath({ flag: false }, ['flag'])).toBe(true) - expect(hasPath({ nested: { key: undefined } }, ['nested', 'key'])).toBe(true) - expect(hasPath({}, ['missing'])).toBe(false) - expect(hasPath({ leaf: 'x' }, ['leaf', 'deeper'])).toBe(false) - expect(hasPath({ models: ['a'] }, ['models', '0'])).toBe(true) - expect(hasPath({ models: ['a'] }, ['models', '1'])).toBe(false) - expect(hasPath({ root: true }, [])).toBe(true) - expect(hasPath(undefined, [])).toBe(false) - }) - - it('sets nested paths immutably, materializing containers by key shape', () => { - const draft = {} - const next = setPath(draft, ['providers', 'openai', 'baseURL'], 'https://y') - expect(draft).toEqual({}) - expect(next).toEqual({ providers: { openai: { baseURL: 'https://y' } } }) - const withArray = setPath(next, ['models', '0'], { id: 'a' }) - expect(withArray).toEqual({ providers: { openai: { baseURL: 'https://y' } }, models: [{ id: 'a' }] }) - const replaced = setPath(withArray, ['models', '0', 'id'], 'b') - expect(replaced.models).toEqual([{ id: 'b' }]) - expect((withArray as { models: unknown[] }).models).toEqual([{ id: 'a' }]) - expect(() => setPath({}, [], 'x')).toThrow(/non-empty path/) - }) - - it('deletes nested paths immutably and splices array indexes', () => { - const draft = { providers: { openai: { baseURL: 'https://x', apiKey: 'k' } }, models: ['a', 'b'] } - const withoutKey = deletePath(draft, ['providers', 'openai', 'apiKey']) - expect(withoutKey).toEqual({ providers: { openai: { baseURL: 'https://x' } }, models: ['a', 'b'] }) - expect(draft.providers.openai.apiKey).toBe('k') - const withoutModel = deletePath(withoutKey, ['models', '0']) - expect(withoutModel.models).toEqual(['b']) - expect(deletePath(draft, ['providers', 'missing', 'x'])).toBe(draft) - expect(() => deletePath({}, [])).toThrow(/non-empty path/) - }) - - it('deletes keys through array intermediates immutably', () => { - const draft = { models: [{ id: 'a', contextWindow: 1 }] } - const next = deletePath(draft, ['models', '0', 'contextWindow']) - expect(next).toEqual({ models: [{ id: 'a' }] }) - expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 }) - }) -}) - -describe('nodeAtPath', () => { - const Root = Schema.object({ - providers: Schema.dict(Schema.object({ baseURL: Schema.string() })), - models: Schema.array(Schema.object({ id: Schema.string() })), - leaf: Schema.string(), - }) - - it('resolves object, dict, and array positions', () => { - const root = rehydrateSchema(Wire(Root)) - expect(nodeAtPath(root, [])).toBe(root) - expect(nodeAtPath(root, ['providers', 'openai'])?.type).toBe('object') - expect(nodeAtPath(root, ['providers', 'openai', 'baseURL'])?.type).toBe('string') - expect(nodeAtPath(root, ['models', '0', 'id'])?.type).toBe('string') - expect(nodeAtPath(root, ['missing'])).toBeUndefined() - expect(nodeAtPath(root, ['missing', 'deeper'])).toBeUndefined() - expect(nodeAtPath(root, ['leaf', 'below'])).toBeUndefined() - }) - - it('tolerates structural nodes missing their relation maps', () => { - expect(nodeAtPath({ type: 'object' } as never, ['x'])).toBeUndefined() - expect(nodeAtPath({ type: 'dict' } as never, ['x'])).toBeUndefined() - }) -}) diff --git a/packages/client/schema-form/tsconfig.json b/packages/client/schema-form/tsconfig.json deleted file mode 100644 index 34abf11c47..0000000000 --- a/packages/client/schema-form/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/client/schema-form/tsdown.config.ts b/packages/client/schema-form/tsdown.config.ts deleted file mode 100644 index b03542c74e..0000000000 --- a/packages/client/schema-form/tsdown.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clientLibrary } from '../tsdown.client.ts' - -export default clientLibrary( - '@deepseek-ai/dsh-client-schema-form', - ['lib/types/index.js', 'lib/types/invariant.js'], -) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 3da9b90cfb..09fd84275b 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -53,15 +53,11 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-permission-presets": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -69,7 +65,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index aec82bf9d9..68606219bc 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -43,7 +43,7 @@ export type { } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote'] +export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsSchema'] const ACCESS_NS = 'permission.access' @@ -113,7 +113,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') const connection = ctx.get('connection') as ConnectionHandle - const controller = new PermissionPresetSettingsController(connection.api) + const controller = new PermissionPresetSettingsController(connection.api, ctx.settingsSchema) const load = (): Promise => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 6e7199f1be..f69f6a6ec6 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -10,9 +10,7 @@ import type { import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' -import { - nodeAtPath, rehydrateSchema, type SchemaNode, -} from '@deepseek-ai/dsh-client-schema-form' +import type { SchemaNode, SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' import { displayPermissionPreset } from './presentation.ts' /** Permission's settings namespace on the host wire. */ @@ -47,13 +45,13 @@ interface ConstChoice { * @param view - permission namespace descriptor. * @returns current value and selectable options. */ -export function permissionDefaultOf(view: SettingsNamespaceView): { +export function permissionDefaultOf(view: SettingsNamespaceView, schema: SettingsSchemaService): { currentValue: string options: PermissionDefaultOption[] } { const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value') - const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset']) + const node = schema.nodeAtPath(schema.rehydrate(view.schema), ['defaultPreset']) if (node === undefined) throw new Error('permission settings schema has no defaultPreset field') const rawChoices = node.type === 'union' ? (node.list as SchemaNode[] | undefined) ?? [] @@ -91,7 +89,10 @@ export class PermissionPresetSettingsController { private view: SettingsNamespaceView | undefined /** @param api - Settings wire face. */ - constructor(private readonly api: Pick) {} + constructor( + private readonly api: Pick, + private readonly schema: SettingsSchemaService, + ) {} /** * Refresh the permission descriptor. Latest request wins. @@ -161,7 +162,7 @@ export class PermissionPresetSettingsController { } private accept(view: SettingsNamespaceView, writable: boolean): void { - const resolved = permissionDefaultOf(view) + const resolved = permissionDefaultOf(view, this.schema) this.view = view this.store.update((state) => { state.status = 'ready' diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 9df3920bd5..f7a1ad0737 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { en } from '../src/client/locales.ts' import { PermissionPresetSettingsController } from '../src/client/settings-store.ts' @@ -20,6 +22,12 @@ const SCHEMA = { }, } +const schema = new SettingsSchemaService(new Context()) + +function createController(api: ConstructorParameters[0]) { + return new PermissionPresetSettingsController(api, schema) +} + function view(defaultPreset: string, revision = 0): SettingsNamespaceView { return { ns: 'permission', @@ -58,7 +66,7 @@ function mount(controller: PermissionPresetSettingsController) { describe('PermissionRow', () => { it('loads the descriptor, opens the menu, and selects a new default', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -85,7 +93,7 @@ describe('PermissionRow', () => { it('requires explicit acknowledgement before saving Full access', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -109,7 +117,7 @@ describe('PermissionRow', () => { }) it('hides an unavailable namespace and disables a read-only provider', async () => { - const absent = new PermissionPresetSettingsController({ + const absent = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), mutate: vi.fn(), @@ -119,7 +127,7 @@ describe('PermissionRow', () => { await waitFor(() => { expect(rendered.container.textContent).toBe('') }) rendered.unmount() - const readonly = new PermissionPresetSettingsController({ + const readonly = createController({ settings: { describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })), mutate: vi.fn(), @@ -134,7 +142,7 @@ describe('PermissionRow', () => { writable: boolean namespaces: SettingsNamespaceView[] }>>>() - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe: () => describe.promise, mutate: () => Promise.resolve({ diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index e4e218fe86..b04c954d83 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -1,5 +1,7 @@ +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' import { PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, } from '../src/client/settings-store.ts' @@ -14,6 +16,16 @@ const SCHEMA = { }, } +const schema = new SettingsSchemaService(new Context()) + +function resolveDefault(view: SettingsNamespaceView) { + return permissionDefaultOf(view, schema) +} + +function createController(api: ConstructorParameters[0]) { + return new PermissionPresetSettingsController(api, schema) +} + function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView { return { ns: 'permission', @@ -32,7 +44,7 @@ function ok(value: T) { describe('permission settings store', () => { it('derives dynamic options and host labels from the descriptor schema', () => { - expect(permissionDefaultOf(view('read-only'))).toEqual({ + expect(resolveDefault(view('read-only'))).toEqual({ currentValue: 'read-only', options: [ { id: 'read-only', label: 'Read Only' }, @@ -46,7 +58,7 @@ describe('permission settings store', () => { 2: { type: 'object', dict: { defaultPreset: 1 } }, }, } - expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({ + expect(resolveDefault(view('read-only', 0, single))).toEqual({ currentValue: 'read-only', options: [{ id: 'read-only', label: 'Read Only' }], }) @@ -57,23 +69,23 @@ describe('permission settings store', () => { 2: { type: 'object', dict: { defaultPreset: 1 } }, }, } - expect(permissionDefaultOf(view('read-only', 0, undescribed)).options) + expect(resolveDefault(view('read-only', 0, undescribed)).options) .toEqual([{ id: 'read-only', label: 'Read Only' }]) }) it('rejects malformed values and dynamic enums at the wire boundary', () => { - expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/) - expect(() => permissionDefaultOf(view('read-only', 0, { + expect(() => resolveDefault({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/) + expect(() => resolveDefault(view('read-only', 0, { uid: 1, refs: { 1: { type: 'object', dict: {} } }, }))).toThrow(/no defaultPreset field/) - expect(() => permissionDefaultOf(view('read-only', 0, { + expect(() => resolveDefault(view('read-only', 0, { uid: 2, refs: { 1: { type: 'union' }, 2: { type: 'object', dict: { defaultPreset: 1 } }, }, }))).toThrow(/does not advertise/) - expect(() => permissionDefaultOf(view('read-only', 0, { + expect(() => resolveDefault(view('read-only', 0, { uid: 4, refs: { 1: { type: 'string' }, @@ -82,7 +94,7 @@ describe('permission settings store', () => { 4: { type: 'object', dict: { defaultPreset: 3 } }, }, }))).toThrow(/does not advertise/) - expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/) + expect(() => resolveDefault(view('missing'))).toThrow(/does not advertise/) }) it('loads and writes defaultPreset with optimistic concurrency', async () => { @@ -92,7 +104,7 @@ describe('permission settings store', () => { namespaces: [view('read-only', 4)], }))) const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe, mutate } as never, }) await controller.load() @@ -117,13 +129,13 @@ describe('permission settings store', () => { it('hides the row when the namespace is absent and contains write failures', async () => { const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))) - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe, mutate: vi.fn() } as never, }) await controller.load() expect(controller.store.getSnapshot().status).toBe('unavailable') - const failing = new PermissionPresetSettingsController({ + const failing = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => Promise.resolve({ @@ -149,7 +161,7 @@ describe('permission settings store', () => { .mockImplementationOnce(() => first.promise) .mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] })) const mutate = vi.fn() - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe, mutate } as never, }) const stale = controller.load() @@ -164,7 +176,7 @@ describe('permission settings store', () => { await controller.select('workspace-write') expect(mutate).not.toHaveBeenCalled() - const rejected = new PermissionPresetSettingsController({ + const rejected = createController({ settings: { describe: () => Promise.resolve({ rpcId: 'test', @@ -177,7 +189,7 @@ describe('permission settings store', () => { await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) - const thrown = new PermissionPresetSettingsController({ + const thrown = createController({ settings: { // Promise consumers must contain unknown rejection values from a // transport implementation, including non-Error legacy clients. @@ -196,7 +208,7 @@ describe('permission settings store', () => { namespaces: SettingsNamespaceView[] }>>>() const describe = vi.fn(() => read.promise) - const idle = new PermissionPresetSettingsController({ settings: { describe, mutate: vi.fn() } as never }) + const idle = createController({ settings: { describe, mutate: vi.fn() } as never }) refreshPermissionIfLoaded(idle) expect(describe).not.toHaveBeenCalled() const loading = idle.load() @@ -209,7 +221,7 @@ describe('permission settings store', () => { writable: boolean namespaces: SettingsNamespaceView[] }>>>() - const disposedRead = new PermissionPresetSettingsController({ + const disposedRead = createController({ settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, }) const reading = disposedRead.load() @@ -224,7 +236,7 @@ describe('permission settings store', () => { hasDocument: false, namespaces: [view('read-only')], }))) - const active = new PermissionPresetSettingsController({ + const active = createController({ settings: { describe: activeDescribe, mutate: () => mutation.promise, @@ -240,7 +252,7 @@ describe('permission settings store', () => { expect(active.store.getSnapshot().status).toBe('saving') const rejectedMutation = Promise.withResolvers>>() - const disposedWrite = new PermissionPresetSettingsController({ + const disposedWrite = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => rejectedMutation.promise, diff --git a/packages/client/ui-permission-presets/tsconfig.json b/packages/client/ui-permission-presets/tsconfig.json index e614c95723..5c72a81455 100644 --- a/packages/client/ui-permission-presets/tsconfig.json +++ b/packages/client/ui-permission-presets/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../runtime" }, - { - "path": "../schema-form" - }, { "path": "../ui-commands" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index dd312defcf..82b04435ab 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -50,19 +50,15 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 24e2112096..4ee5c5688e 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -101,6 +101,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): provider={row.entry.provider} displayName={row.entry.displayName} namespace={namespace} + schema={controller.schema} settingsPath={row.entry.settingsPath} api={api} t={t} diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 5fe5647b88..c5e72c8b44 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -63,7 +63,7 @@ interface EditorTarget extends ProviderIdentity { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'api' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'api' | 't' | 'readOnly' | 'onClose' > { target: EditorTarget } @@ -269,7 +269,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { // Hand-declared routes live in the pi-ai namespace, which is also the only // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. - const protocols = protocolChoices(state.namespaces.get('llm-pi-ai')) + const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), controller.schema) return (
@@ -297,6 +297,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { {renderProviderEditor({ target, namespace, + schema: controller.schema, api, t, readOnly: !state.writable, @@ -381,6 +382,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ? renderProviderEditor({ target, namespace, + schema: controller.schema, api, t, readOnly: !state.writable, @@ -419,6 +421,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { displayName={addTarget.displayName} hideTitle namespace={addNamespace} + schema={controller.schema} settingsPath={addTarget.settingsPath} api={api} t={t} diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 63d25b2eb6..76e6bc0ded 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -24,9 +24,7 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' -import { - deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, -} from '@deepseek-ai/dsh-client-schema-form' +import type { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' import { DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, } from './DeepSeekModelsEditor.tsx' @@ -61,6 +59,8 @@ export interface ProviderEditorProps { declared?: boolean /** The owning namespace view (schema, layers, secrets). */ namespace: SettingsNamespaceView + /** Settings-owned synchronous schema and immutable path operations. */ + schema: SettingsSchemaService /** Path from the section root to this provider's profile. */ settingsPath: readonly string[] /** Wire faces for writes and for interrogating a provider endpoint. */ @@ -86,8 +86,12 @@ export interface ProviderEditorProps { } /** A user-section subtree as a plain draft object (absent → empty). */ -function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Record { - const subtree = getPath(namespace.user, path) +function draftAt( + schema: SettingsSchemaService, + namespace: SettingsNamespaceView, + path: readonly string[], +): Record { + const subtree = schema.getPath(namespace.user, path) if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {} return structuredClone(subtree) as Record } @@ -129,8 +133,13 @@ function layoutOf(ns: string): EditorLayout { } /** The credential reference this profile resolves keys through. */ -function refFor(namespace: SettingsNamespaceView, path: readonly string[], provider: string): string { - const profile = getPath(namespace.value, path) +function refFor( + schema: SettingsSchemaService, + namespace: SettingsNamespaceView, + path: readonly string[], + provider: string, +): string { + const profile = schema.getPath(namespace.value, path) const named = typeof profile === 'object' && profile !== null ? (profile as { apiKeyEnv?: unknown }).apiKeyEnv : undefined @@ -143,8 +152,8 @@ function refFor(namespace: SettingsNamespaceView, path: readonly string[], provi * @returns the editor card. */ export function ProviderEditor(props: ProviderEditorProps): ReactNode { - const { namespace, settingsPath, api, t } = props - const [draft, setDraft] = useState>(() => draftAt(namespace, settingsPath)) + const { namespace, schema, settingsPath, api, t } = props + const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) @@ -153,22 +162,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // derived fields in the draft prevents a pushed namespace refresh from // turning them into deletions when the following credential write is retried. const [committedOriginal, setCommittedOriginal] = useState( - () => getPath(namespace.user, settingsPath), + () => schema.getPath(namespace.user, settingsPath), ) const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision) - const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema]) - const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath]) - const fallback = getPath(namespace.value, settingsPath) + const root = useMemo(() => schema.rehydrate(namespace.schema), [namespace.schema, schema]) + const node = useMemo(() => schema.nodeAtPath(root, settingsPath), [root, schema, settingsPath]) + const fallback = schema.getPath(namespace.value, settingsPath) const disabled = props.readOnly || busy const layout = layoutOf(namespace.ns) - const keyRef = refFor(namespace, settingsPath, props.provider) + const keyRef = refFor(schema, namespace, settingsPath, props.provider) // The same schema read the create card makes, so the choices offered here // and there cannot drift apart: both come from the adapter's own `Config`. // Only the pi-ai layout has a per-route protocol for the read to find, and // it rehydrates the whole section schema, so the other layouts skip it. const protocols = useMemo( - () => layout === 'pi-ai' ? protocolChoices(namespace) : [], - [layout, namespace], + () => layout === 'pi-ai' ? protocolChoices(namespace, schema) : [], + [layout, namespace, schema], ) useEffect(() => { @@ -189,7 +198,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }, [api.credentials, keyRef]) const stringAt = (source: unknown, key: string): string | undefined => { - const value = getPath(source, [key]) + const value = schema.getPath(source, [key]) return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { @@ -198,12 +207,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // while the draft still carried the spaces into `settings.yaml`, where // both adapters would accept that non-empty string as a real value. const value = next === undefined || next.trim().length === 0 ? undefined : next - setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value)) + setDraft(current => value === undefined + ? schema.deletePath(current, [key]) + : schema.setPath(current, [key], value)) } // The model list is validated by the same per-row checker for both families, // so a bad row is named by its position rather than by a blanket message. - const modelFailure = validateDeepSeekModels(getPath(draft, ['models'])) + const modelFailure = validateDeepSeekModels(schema.getPath(draft, ['models'])) const keyFailure = apiKeyFailure(keyDraft) // What a probe or a write must carry: the typed key with paste whitespace // removed. A blank field yields an empty string, which both call sites read @@ -240,14 +251,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // about to store a key. Otherwise the provider keeps its native auth path. const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined && stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0 - ? setPath(draft, ['apiKeyEnv'], keyRef) + ? schema.setPath(draft, ['apiKeyEnv'], keyRef) : draft if (props.credentialOnly !== true) { // The same checker gates the submit button, so a card cannot reach this // with a bad row; it stays because the schema check below would refuse // the write with a message naming a path instead of the row, and because // nothing but this function decides what is written. - const failure = validateDeepSeekModels(getPath(next, ['models'])) + const failure = validateDeepSeekModels(schema.getPath(next, ['models'])) /* v8 ignore next 3 -- unreachable from the card: the same failure disables submit */ if (failure !== undefined) { return `${t('model')} ${String(failure.index + 1)}: ${t(failure.key)}` @@ -255,7 +266,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { } /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */ if (props.credentialOnly !== true && node !== undefined && settingsPath.length === 0) { - const sectionError = validateDraft(node, next) + const sectionError = schema.validate(node, next) if (sectionError !== undefined) return sectionError } const materializesNativeProfile = layout === 'pi-ai' @@ -274,7 +285,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ? t('conflict') : response.result.error.message } - setCommittedOriginal(getPath(response.result.value.user, settingsPath)) + setCommittedOriginal(schema.getPath(response.result.value.user, settingsPath)) setExpectedRevision(response.result.value.revision) setDraft(next) } @@ -322,8 +333,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { * moment reset drops it, leaving the rows unchanged until a reload. */ const inheritedModels = (): unknown => { - const pinned = getPath(namespace.base, [...settingsPath, 'models']) - return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default + const pinned = schema.getPath(namespace.base, [...settingsPath, 'models']) + return pinned ?? schema.nodeAtPath(root, [...settingsPath, 'models'])?.meta.default } /** @@ -336,11 +347,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // A whole-section `llm-deepseek` profile is a composition fact with no // per-route identity for its schema to carry, hence the family test. const ownsIdentity = family === 'pi-ai' && props.declared === true - const customModels = getPath(draft, ['models']) - const modelsOverridden = hasPath(draft, ['models']) + const customModels = schema.getPath(draft, ['models']) + const modelsOverridden = schema.hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) - const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) - const defaultMaxTokens = getPath(fallback, ['maxTokens']) + const defaultContextWindow = schema.getPath(fallback, ['defaultContextWindow']) + const defaultMaxTokens = schema.getPath(fallback, ['maxTokens']) const keyPlaceholder = keyLocked ? t('keyEnvLocked') : keyState?.configured === true && props.credentialRequired !== true @@ -353,9 +364,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { t, disabled, onChange: (next: Record[]) => { - setDraft(current => setPath(current, ['models'], next)) + setDraft(current => schema.setPath(current, ['models'], next)) }, - onReset: () => { setDraft(current => deletePath(current, ['models'])) }, + onReset: () => { setDraft(current => schema.deletePath(current, ['models'])) }, } return ( <> @@ -397,7 +408,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // the answer the route id. Reading the effective value // instead would echo the stored override back as the // thing clearing restores. - placeholder={stringAt(getPath(namespace.base, settingsPath), 'displayName') + placeholder={stringAt(schema.getPath(namespace.base, settingsPath), 'displayName') ?? props.provider} aria-label={t('customDisplayName')} disabled={disabled} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index dc7f32e370..d4bb0dfb03 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -56,7 +56,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registration depends on each slot through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsSchema'] /** * Register the Models section once the `settings.section` declaration is on @@ -68,7 +68,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const connection = ctx.get('connection') as ConnectionHandle - const controller = new ModelsSettingsStore(connection.api) + const controller = new ModelsSettingsStore(connection.api, ctx.settingsSchema) const useSnapshot = bindSnapshotSelector(controller.store) // Registration-time text (the nav label thunk) and the inject faces share // one bound translate; copy freshness rides the locale revision. diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 4389b9a6cb..e970602815 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -11,7 +11,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form' +import type { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' /** * Any route key walks a dict schema to the same profile node, so the lookup @@ -78,18 +78,25 @@ export function deriveKeyRef(provider: string): string { * @param namespace - the namespace view whose schema declares the profile shape. * @returns the protocol identifiers, or an empty list when the schema has none. */ -export function protocolChoices(namespace: SettingsNamespaceView | undefined): string[] { +export function protocolChoices( + namespace: SettingsNamespaceView | undefined, + schema: SettingsSchemaService, +): string[] { if (namespace === undefined) return [] - const node = nodeAtPath(rehydrateSchema(namespace.schema), ['providers', PROBE_ROUTE, 'api']) + const node = schema.nodeAtPath(schema.rehydrate(namespace.schema), ['providers', PROBE_ROUTE, 'api']) const list = (node as { type?: string; list?: readonly { value?: unknown }[] } | undefined) if (list?.type !== 'union' || list.list === undefined) return [] return list.list.map(entry => entry.value).filter((value): value is string => typeof value === 'string') } /** The credential reference a resolved profile names (its `apiKeyEnv` field). */ -function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined { +function apiKeyEnvOf( + namespace: SettingsNamespaceView | undefined, + path: readonly string[], + schema: SettingsSchemaService, +): string | undefined { if (namespace === undefined) return undefined - const profile = getPath(namespace.value, path) + const profile = schema.getPath(namespace.value, path) if (typeof profile !== 'object' || profile === null) return undefined const ref = (profile as { apiKeyEnv?: unknown }).apiKeyEnv return typeof ref === 'string' && ref.length > 0 ? ref : undefined @@ -108,7 +115,10 @@ export class ModelsSettingsStore { /** * @param api - the wire face (settings/credentials/llm domains). */ - constructor(private readonly api: Pick) {} + constructor( + private readonly api: Pick, + readonly schema: SettingsSchemaService, + ) {} /** * Refresh the whole page snapshot: directory and namespaces in parallel, @@ -144,16 +154,16 @@ export class ModelsSettingsStore { const rows: ProviderRow[] = providers.map((entry) => { const namespace = namespaces.get(entry.settingsNs) const configured = namespace !== undefined - && (entry.settingsPath.length === 0 || getPath(namespace.value, entry.settingsPath) !== undefined) + && (entry.settingsPath.length === 0 || this.schema.getPath(namespace.value, entry.settingsPath) !== undefined) const removable = namespace !== undefined && entry.settingsPath.length > 0 - && hasPath(namespace.user, entry.settingsPath) - && !hasPath(namespace.base, entry.settingsPath) + && this.schema.hasPath(namespace.user, entry.settingsPath) + && !this.schema.hasPath(namespace.base, entry.settingsPath) return { entry, configured, removable, - apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath), + apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath, this.schema), credential: undefined, } }) diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 66ba8d33a4..4a90b5f353 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -17,6 +17,7 @@ import { apiKeyFailure } from '../src/client/apiKey.ts' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' +import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -185,7 +186,7 @@ type WireFace = ConstructorParameters[0] async function mountFace(scripted: ReturnType) { const { face, update, replace, mutate, set, unset } = scripted - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() const injected: ModelsSectionInjected = { controller, @@ -265,7 +266,7 @@ describe('ModelsSection', () => { face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() render( { face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() cleanup() render( { displayName="DeepSeek" hideTitle namespace={wireNamespaces()[0]!} + schema={settingsSchema} settingsPath={[]} api={face as never} t={t} @@ -621,6 +623,7 @@ describe('ModelsSection', () => { provider="deepseek-official" displayName="DeepSeek" namespace={overridden} + schema={settingsSchema} settingsPath={[]} api={face as never} t={t} @@ -850,6 +853,7 @@ describe('ModelsSection', () => { provider="deepseek-official" displayName="DeepSeek" namespace={bare} + schema={settingsSchema} settingsPath={[]} api={face as never} t={t} @@ -1007,7 +1011,7 @@ describe('ModelsSection', () => { const unhandled = vi.fn() process.on('unhandledRejection', unhandled) try { - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() render( { it('renders the load failure with a retry control', async () => { const face = scriptedFace() face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never - const controller = new ModelsSettingsStore(face.face as unknown as WireFace) + const controller = new ModelsSettingsStore(face.face as unknown as WireFace, settingsSchema) await controller.load() render( { hasDocument: false, namespaces: wireNamespaces(), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() cleanup() render( { it('loads on first render of an idle controller', async () => { const { face } = scriptedFace() - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) render( { cleanup() @@ -124,7 +125,7 @@ function harness(options: { set, }, } - const controller = new ModelsSettingsStore(face as never) + const controller = new ModelsSettingsStore(face as never, settingsSchema) const openSection = vi.fn() const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 246c7d64b1..6681b9e4d7 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -11,6 +11,7 @@ import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' +import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -139,7 +140,7 @@ function firstMutate(mutate: ReturnType): MutateCall { async function mountSection(options: Parameters[0] = {}) { const scripted = scriptedFace(options) - const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace) + const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, settingsSchema) await controller.load() const injected: ModelsSectionInjected = { controller, @@ -183,10 +184,10 @@ function within_(scope: HTMLElement, label: string): HTMLElement { describe('protocolChoices', () => { it('reads the protocols out of the namespace schema and nothing else', async () => { const { namespace } = scriptedFace() - expect(protocolChoices(namespace)).toEqual(PROTOCOLS) - expect(protocolChoices(undefined)).toEqual([]) + expect(protocolChoices(namespace, settingsSchema)).toEqual(PROTOCOLS) + expect(protocolChoices(undefined, settingsSchema)).toEqual([]) const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown } - expect(protocolChoices(plain)).toEqual([]) + expect(protocolChoices(plain, settingsSchema)).toEqual([]) await Promise.resolve() }) }) @@ -637,7 +638,7 @@ describe('provider rows', () => { active: true, }], }))) as never - const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace) + const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, settingsSchema) await controller.load() render((value: T): RpcResponse { @@ -72,7 +73,7 @@ function api(overrides: { describe('ModelsSettingsStore', () => { it('joins rows with configured, removable, and credential state', async () => { const { face, seenRefs } = api() - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -100,7 +101,7 @@ describe('ModelsSettingsStore', () => { it('degrades the credential badge, not the page, when the credential domain fails', async () => { const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -112,7 +113,7 @@ describe('ModelsSettingsStore', () => { const { face } = api({ describeCredentials: () => Promise.reject(new Error('credential transport down')), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await expect(store.load()).resolves.toBeUndefined() expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', @@ -125,18 +126,18 @@ describe('ModelsSettingsStore', () => { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario describeCredentials: () => Promise.reject('credential transport refusal'), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await expect(store.load()).resolves.toBeUndefined() expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal') }) it('surfaces a directory failure and keeps the last good rows', async () => { const { face } = api() - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() expect(store.store.getSnapshot().rows).toHaveLength(4) const broken = api({ providers: () => Promise.resolve(fail('directory down')) }) - const failing = new ModelsSettingsStore(broken.face) + const failing = new ModelsSettingsStore(broken.face, settingsSchema) await failing.load() expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' }) // The first store's snapshot is untouched by the second's failure. @@ -157,7 +158,7 @@ describe('ModelsSettingsStore', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) const first = store.load() const second = store.load() release?.() @@ -187,7 +188,7 @@ describe('edge joins', () => { ] as never, })), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() const state = store.store.getSnapshot() expect(state.rows[0]).toMatchObject({ configured: true, removable: false }) @@ -207,7 +208,7 @@ describe('edge joins', () => { ] as never, })), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() expect(seenRefs).toEqual([]) expect(store.store.getSnapshot().status).toBe('ready') @@ -215,7 +216,7 @@ describe('edge joins', () => { it('surfaces a settings describe failure', async () => { const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) }) @@ -224,7 +225,7 @@ describe('edge joins', () => { // The wire can surface non-Error throwables; the store must stringify them. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario const { face } = api({ providers: () => Promise.reject('plain refusal') }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' }) }) @@ -243,7 +244,7 @@ describe('edge joins', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) const first = store.load() const second = store.load() await second diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index a85bfbcc90..2dc6cc2a32 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../runtime" }, - { - "path": "../schema-form" - }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 2934097b94..56bcdb0d98 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, @@ -52,7 +52,7 @@ async function bench(served?: string[]) { credentials: { describe: describeCredentials }, }, } as never) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings } } diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index d86f5f86f2..1d3669d008 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -44,28 +44,28 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-settings": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-connection": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 2ace9e56b1..6e8310242e 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -9,6 +9,7 @@ * through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { SettingsSchemaService } from './schema.ts' import { SettingsScopeBinder } from './settings-scope.ts' export type { @@ -16,6 +17,8 @@ export type { SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' +export { SettingsSchemaService } from './schema.ts' +export type { SchemaNode } from './schema.ts' /** * Required services: none. The transport is resolved per caller through @@ -31,5 +34,6 @@ export const inject = [] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - new SettingsScopeBinder(ctx) + const schema = new SettingsSchemaService(ctx) + new SettingsScopeBinder(ctx, schema) } diff --git a/packages/client/ui-settings/src/client/schema.ts b/packages/client/ui-settings/src/client/schema.ts new file mode 100644 index 0000000000..4d922bd4e7 --- /dev/null +++ b/packages/client/ui-settings/src/client/schema.ts @@ -0,0 +1,121 @@ +/** Synchronous schema introspection and immutable settings-draft edits. */ +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' + +/** Live schemastery node used for settings introspection and validation. */ +export type SchemaNode = Schema + +function cloneContainer(container: unknown, key: string): Record | unknown[] { + if (Array.isArray(container)) return [...container as unknown[]] + if (typeof container === 'object' && container !== null) return { ...container as Record } + return /^\d+$/.test(key) ? [] : {} +} + +function cloneSpine(root: Record, path: readonly string[]): { + result: Record + parent: Record | unknown[] + leaf: string +} { + const result = { ...root } + let target: Record | unknown[] = result + for (let index = 0; index < path.length - 1; index++) { + const key = path[index] as string + const child = cloneContainer( + Array.isArray(target) ? target[Number(key)] : target[key], + path[index + 1] as string, + ) + if (Array.isArray(target)) target[Number(key)] = child + else target[key] = child + target = child + } + return { result, parent: target, leaf: path[path.length - 1] as string } +} + +/** + * Settings-owned synchronous schema service. Dynamic client plugins receive + * this Cordis entity instead of importing executable helpers from one another. + */ +export class SettingsSchemaService extends Service { + /** @param ctx - providing ui-settings context. */ + constructor(ctx: Context) { + super(ctx, 'settingsSchema') + } + + /** Rehydrate one serialized `schema.toJSON()` envelope. */ + rehydrate(serialized: unknown): SchemaNode { + return new Schema(serialized as Schema) + } + + /** Return a validation failure message, or `undefined` for a valid draft. */ + validate(schema: SchemaNode, draft: unknown): string | undefined { + try { + ;(schema as unknown as (value: unknown) => unknown)(draft) + return undefined + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + } + + /** Resolve an object, dict, or array schema node at a settings path. */ + nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined { + let node: SchemaNode | undefined = root + for (const key of path) { + if (node === undefined) return undefined + if (node.type === 'object') node = (node.dict as Record | undefined)?.[key] + else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined + else return undefined + } + return node + } + + /** Read a nested value by a string-key or array-index path. */ + getPath(value: unknown, path: readonly string[]): unknown { + let current: unknown = value + for (const key of path) { + if (Array.isArray(current)) { + current = current[Number(key)] + continue + } + if (typeof current !== 'object' || current === null) return undefined + current = (current as Record)[key] + } + return current + } + + /** Report whether the final path key exists independently of its value. */ + hasPath(value: unknown, path: readonly string[]): boolean { + if (path.length === 0) return value !== undefined + const parent = this.getPath(value, path.slice(0, -1)) + const key = path[path.length - 1] as string + if (Array.isArray(parent)) return Number(key) < parent.length + if (typeof parent !== 'object' || parent === null) return false + return key in parent + } + + /** Immutably set a nested value, materializing missing containers. */ + setPath(root: Record, path: readonly string[], value: unknown): Record { + if (path.length === 0) throw new Error('ui-settings: setPath needs a non-empty path') + const { result, parent, leaf } = cloneSpine(root, path) + if (Array.isArray(parent)) parent[Number(leaf)] = value + else parent[leaf] = value + return result + } + + /** Immutably remove a nested key, preserving an unchanged missing root. */ + deletePath(root: Record, path: readonly string[]): Record { + if (path.length === 0) throw new Error('ui-settings: deletePath needs a non-empty path') + if (!this.hasPath(root, path)) return root + const { result, parent, leaf } = cloneSpine(root, path) + if (Array.isArray(parent)) parent.splice(Number(leaf), 1) + else Reflect.deleteProperty(parent, leaf) + return result + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Settings-owned synchronous schema and immutable path operations. */ + settingsSchema: SettingsSchemaService + } +} diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 4668c4924b..c3b941663e 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -10,7 +10,6 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' -import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' import { createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, type SettingsScopeSpec, type SnapshotStore, @@ -31,6 +30,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' +import type { SettingsSchemaService } from './schema.ts' type SettingsFace = Pick /** @@ -55,6 +55,7 @@ export class SettingsScopeController implements SettingsScope { private readonly api: SettingsFace, private readonly spec: SettingsScopeSpec, private readonly persistence: 'host' | 'memory' = 'host', + private readonly schema?: SettingsSchemaService, ) { this.store = createSnapshotStore>({ status: persistence === 'host' ? 'loading' : 'unavailable', @@ -201,7 +202,8 @@ export class SettingsScopeController implements SettingsScope { if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined let failure: string | undefined try { - failure = validateDraft(rehydrateSchema(view.schema), view.value) + if (this.schema === undefined) throw new Error('ui-settings: schema service unavailable') + failure = this.schema.validate(this.schema.rehydrate(view.schema), view.value) } catch (_malformedSchemaEnvelope) { // A schema envelope this client cannot rehydrate vouches for no section; // the value is treated exactly like a schema-invalid one. @@ -228,7 +230,7 @@ export class SettingsScopeBinder extends Service { /** * @param ctx - the providing plugin's context. */ - constructor(ctx: Context) { + constructor(ctx: Context, private readonly schema: SettingsSchemaService) { super(ctx, 'settingsScope') } @@ -249,6 +251,7 @@ export class SettingsScopeBinder extends Service { connection.api, spec, connection.isLoopback ? 'host' : 'memory', + this.schema, ) ctx.effect(() => { const refresh = (namespace?: string): void => { diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 429002028a..627034f217 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' +import { SettingsSchemaService } from '../src/client/schema.ts' import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts' interface UiTestSettings { @@ -379,7 +380,7 @@ describe('SettingsScopeBinder.bind', () => { } as never) let scope!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { @@ -411,7 +412,7 @@ describe('SettingsScopeBinder.bind', () => { } as never) let scope!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index 5ef3ae74a0..fa84d80082 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -18,7 +18,7 @@ "path": "../runtime" }, { - "path": "../schema-form" + "path": "../../../vendor/schemastery" }, { "path": "../../api/remotes/tsconfig.client.json" diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index fb84c9860d..67a10937ce 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' @@ -56,7 +56,7 @@ async function bench(isLoopback = true) { ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, setHostPreference: (next: string) => { preference = next }, From 3e4ad10d0527440188cc02a5af4c4e08a2ef680d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:48:05 +0800 Subject: [PATCH 05/31] refactor(client): make attachment UI a client plugin --- packages/client/ui-attachment/package.json | 39 ++++-- .../src/client/ComposerAttachments.module.css | 4 + .../src/client/ComposerAttachments.tsx | 112 ++++++++++++++++ .../src/client/MessageImages.tsx | 8 ++ .../client/ui-attachment/src/client/index.ts | 20 +++ .../client/ui-attachment/src/client/labels.ts | 45 +++++++ packages/client/ui-attachment/src/index.ts | 18 +-- .../client/ui-attachment/src/invariant.ts | 5 +- packages/client/ui-attachment/tsconfig.json | 9 ++ .../client/ui-attachment/tsdown.config.ts | 39 +----- packages/client/ui-conversation/package.json | 15 ++- .../ui-conversation/src/client/apply.ts | 2 + .../src/client/chat/AssistantMarkdown.tsx | 22 +-- .../src/client/chat/AssistantNodeView.tsx | 4 +- .../src/client/chat/ChatNodeSeat.tsx | 8 +- .../src/client/chat/ChatView.tsx | 17 ++- .../src/client/chat/MessageItem.tsx | 21 ++- .../src/client/contract/slots.ts | 53 +++++++- .../src/client/image-labels.ts | 65 +-------- .../ui-conversation/src/client/index.ts | 4 +- .../src/client/skeleton/InputBar.module.css | 9 -- .../src/client/skeleton/InputBar.tsx | 125 ++---------------- .../tests/chat-branch-tails.client.spec.tsx | 10 +- .../tests/coverage-tails.client.spec.tsx | 15 ++- .../tests/gate-branch-tails.client.spec.tsx | 9 +- .../tests/image-labels.client.spec.tsx | 74 +++++------ .../tests/reasoning-row.client.spec.tsx | 8 +- packages/client/ui-conversation/tsconfig.json | 3 - 28 files changed, 426 insertions(+), 337 deletions(-) create mode 100644 packages/client/ui-attachment/src/client/ComposerAttachments.module.css create mode 100644 packages/client/ui-attachment/src/client/ComposerAttachments.tsx create mode 100644 packages/client/ui-attachment/src/client/MessageImages.tsx create mode 100644 packages/client/ui-attachment/src/client/index.ts create mode 100644 packages/client/ui-attachment/src/client/labels.ts diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 5a1b81e978..8e22c28e57 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", - "description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)", + "description": "Dynamic attachment presentation plugin for conversation input and message-image slots", "version": "0.1.0-rc.7", "publishConfig": { "access": "public" @@ -22,30 +22,53 @@ "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-client-ui-conversation" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "clsx": "^2.0.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" } } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css new file mode 100644 index 0000000000..770a64cef5 --- /dev/null +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -0,0 +1,4 @@ +.rail { + min-width: 0; + padding: 4px 12px 0; +} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx new file mode 100644 index 0000000000..0525c74ce2 --- /dev/null +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { + ComposerAttachment, ComposerAttachmentsProps, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { AttachmentRail } from '../AttachmentRail.tsx' +import type { AttachmentRailItem } from '../AttachmentRail.tsx' +import { DropOverlay } from '../DropOverlay.tsx' +import { ImageLightbox } from '../ImageLightbox.tsx' +import { attachmentRailLabels, dropOverlayLabels, lightboxLabels } from './labels.ts' +import css from './ComposerAttachments.module.css' + +/** Rail item retaining its browser-owned attachment for callbacks. */ +interface ComposerRailItem extends AttachmentRailItem { + attachment: ComposerAttachment +} + +/** Draft-image rail, document drop target, and original-image preview slot entry. */ +export function ComposerAttachments({ + attachments, canAcceptDrop, onAddImages, onRemoveImage, dropLimits, t, +}: ComposerAttachmentsProps) { + const [preview, setPreview] = useState(null) + const [dragActive, setDragActive] = useState(false) + const dragDepth = useRef(0) + const closePreview = useCallback(() => { setPreview(null) }, []) + + useEffect(() => { + if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) + }, [attachments, preview]) + + useEffect(() => { + const hasFiles = (event: globalThis.DragEvent): boolean => + event.dataTransfer?.types.includes('Files') ?? false + const reset = (): void => { + dragDepth.current = 0 + setDragActive(false) + } + const onDragEnter = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + event.preventDefault() + dragDepth.current += 1 + setDragActive(true) + } + const onDragOver = (event: globalThis.DragEvent): void => { + if (!hasFiles(event) || event.dataTransfer === null) return + event.preventDefault() + event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none' + } + const onDragLeave = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + dragDepth.current = Math.max(0, dragDepth.current - 1) + if (dragDepth.current === 0) setDragActive(false) + const leftViewport = event.clientX <= 0 || event.clientY <= 0 + || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight + if ((event.target === document.documentElement || event.target === document.body) && leftViewport) reset() + } + const onDrop = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + event.preventDefault() + reset() + if (canAcceptDrop) onAddImages([...(event.dataTransfer?.files ?? [])]) + } + document.addEventListener('dragenter', onDragEnter) + document.addEventListener('dragover', onDragOver) + document.addEventListener('dragleave', onDragLeave) + document.addEventListener('drop', onDrop) + window.addEventListener('dragend', reset) + return () => { + document.removeEventListener('dragenter', onDragEnter) + document.removeEventListener('dragover', onDragOver) + document.removeEventListener('dragleave', onDragLeave) + document.removeEventListener('drop', onDrop) + window.removeEventListener('dragend', reset) + } + }, [canAcceptDrop, onAddImages]) + + const railItems = useMemo(() => attachments.map(attachment => ({ + id: attachment.id, + previewUrl: attachment.previewUrl, + alt: attachment.file.name || t('image.pending'), + removeLabel: t('image.remove', { name: attachment.file.name }), + attachment, + })), [attachments, t]) + + return ( + <> + {dragActive && ( + + )} + {railItems.length > 0 && ( +
+ { setPreview(item.attachment) }} + onRemove={(item) => { onRemoveImage(item.attachment.id) }} + /> +
+ )} + {preview !== null && ( + + )} + + ) +} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx new file mode 100644 index 0000000000..0d0dac02f8 --- /dev/null +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -0,0 +1,8 @@ +import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ImageGallery } from '../MessageImage.tsx' +import { messageImageLabels } from './labels.ts' + +/** Historical message-image slot entry. */ +export function MessageImages({ images, loadImage, align, t }: MessageImagesProps) { + return +} diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts new file mode 100644 index 0000000000..616e9c7292 --- /dev/null +++ b/packages/client/ui-attachment/src/client/index.ts @@ -0,0 +1,20 @@ +/** Browser attachment plugin: fills conversation's composer and message-image slots. */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ComposerAttachments } from './ComposerAttachments.tsx' +import { MessageImages } from './MessageImages.tsx' + +/** Slot registry required by this presentation plugin. */ +export const inject = ['slots'] + +/** Register attachment presentation without exporting React components as package values. */ +export function apply(ctx: ClientContext): void { + ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({ + name: 'conversation.input.attachments', + locale: 'conversation', + }, ComposerAttachments)) + ctx.slots.inject('conversation.message.images', () => ctx.slots.register({ + name: 'conversation.message.images', + locale: 'conversation', + }, MessageImages)) +} diff --git a/packages/client/ui-attachment/src/client/labels.ts b/packages/client/ui-attachment/src/client/labels.ts new file mode 100644 index 0000000000..cc83d5791b --- /dev/null +++ b/packages/client/ui-attachment/src/client/labels.ts @@ -0,0 +1,45 @@ +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { AttachmentRailLabels } from '../AttachmentRail.tsx' +import type { DropOverlayLabels } from '../DropOverlay.tsx' +import type { ImageLightboxLabels } from '../ImageLightbox.tsx' +import type { MessageImageLabels } from '../MessageImage.tsx' + +/** Resolve original-image lightbox strings from the conversation namespace. */ +export function lightboxLabels(t: TranslateNS<'conversation'>): ImageLightboxLabels { + return { dialog: t('image.preview'), close: t('image.closePreview') } +} + +/** Resolve historical message-image strings from the conversation namespace. */ +export function messageImageLabels(t: TranslateNS<'conversation'>): MessageImageLabels { + return { + image: t('image.label'), + open: t('image.openOriginal'), + openNamed: label => t('image.openOriginalLabel', { label }), + loading: t('image.loading'), + loadFailed: t('image.loadFailed'), + lightbox: lightboxLabels(t), + } +} + +/** Resolve the document-level drop invitation and its optional limits line. */ +export function dropOverlayLabels( + t: TranslateNS<'conversation'>, + accepting: boolean, + limits?: { readonly count: number; readonly size: string }, +): DropOverlayLabels { + if (!accepting) return { title: t('image.dropBlocked') } + return { + title: t('image.dropTitle'), + desc: limits === undefined ? undefined : t('image.dropDesc', limits), + } +} + +/** Resolve draft-image rail strings from the conversation namespace. */ +export function attachmentRailLabels(t: TranslateNS<'conversation'>): AttachmentRailLabels { + return { + group: t('image.pending'), + open: t('image.openOriginal'), + scrollLeft: t('image.scrollLeft'), + scrollRight: t('image.scrollRight'), + } +} diff --git a/packages/client/ui-attachment/src/index.ts b/packages/client/ui-attachment/src/index.ts index bef6c900a6..4bb65a79cc 100644 --- a/packages/client/ui-attachment/src/index.ts +++ b/packages/client/ui-attachment/src/index.ts @@ -1,16 +1,4 @@ -/** - * Pure React attachment atoms (zero cordis): the composer draft-image rail, - * the chat-history image gallery, the original-image lightbox, and the - * full-page drop overlay. Owners resolve every string through their own - * locale namespace and pass it down; nothing here reads application state. - * @module @deepseek-ai/dsh-client-ui-attachment - */ +/** Host half of the browser-only attachment presentation plugin. */ -export { AttachmentRail } from './AttachmentRail.tsx' -export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx' -export { DropOverlay } from './DropOverlay.tsx' -export type { DropOverlayLabels } from './DropOverlay.tsx' -export { ImageLightbox } from './ImageLightbox.tsx' -export type { ImageLightboxLabels } from './ImageLightbox.tsx' -export { ImageGallery, MessageImage } from './MessageImage.tsx' -export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx' +/** No host-side behavior; the client half registers the React slot entries. */ +export function apply(): void {} diff --git a/packages/client/ui-attachment/src/invariant.ts b/packages/client/ui-attachment/src/invariant.ts index 47d18f97b8..5358704929 100644 --- a/packages/client/ui-attachment/src/invariant.ts +++ b/packages/client/ui-attachment/src/invariant.ts @@ -15,9 +15,8 @@ export const name = 'client-ui-attachment-invariant' export const inject = ['invariants'] /** - * No runtime invariant: pure props-in React atoms with no Cordis API — - * no events, no services, no mutable cross-plugin state; rendering contracts - * are asserted directly by this package's component specs. + * No runtime invariant: the package contributes only effect-owned slot entries; + * the slot registry owns their lifecycle and validates their declarations. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index c9ccd04a95..2f5cd3a73e 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -14,6 +14,15 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-slots" + }, { "path": "../ui-primitives" } diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index d8c37d8a2c..e70803de17 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -1,35 +1,6 @@ -import { clientOnly } from '../tsdown.client.ts' +import { clientBundle } from '../tsdown.client.ts' -// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only -// the package differs). On a third atoms package, extract a shared css-stub -// client-library preset in packages/client/tsdown.client.ts instead of a -// fourth copy. -/** - * ui-attachment is browser-only, but its lib bundle IS imported under plain - * Node because the web shell is a lib (dsh-client-web's lib chain reaches - * this package). CSS imports are therefore stubbed to empty modules instead - * of externalized — the hashed class maps only matter in bundler contexts - * (loader module table / vite source paths), which compile src directly and - * never read lib. - */ -export default clientOnly([{ - entry: ['lib/types/index.js', 'lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'neutral', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - plugins: [{ - name: 'dsh-css-stub', - resolveId(source: string) { - if (!source.endsWith('.css')) return null - return `\0dsh-css-stub:${source}.mjs` - }, - load(id: string) { - if (!id.startsWith('\0dsh-css-stub:')) return null - return 'export default {};' - }, - }], -}]) +export default clientBundle( + '@deepseek-ai/dsh-client-ui-attachment', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 12e5c7f6c2..8fd8c44d4c 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -48,7 +48,6 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-settings": "workspace:^", "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" }, @@ -61,11 +60,8 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -73,7 +69,12 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -86,7 +87,6 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", @@ -104,7 +104,8 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-settings": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f57caea9e5..463c481eef 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -282,6 +282,7 @@ export function apply(ctx: Context): void { // access control, model right); empty until their owning plugins // register. children: { + 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, }, @@ -381,6 +382,7 @@ export function apply(ctx: Context): void { locale: NS, children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, + 'conversation.message.images': { kind: 'single', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index bb766da778..c758827317 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,14 +9,12 @@ // their branch action is enabled only when the node is also the completed // turn's transcript tail. Think / tool-head-only nodes stay chrome-free. -import { memo, useMemo } from 'react' +import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import { messageImageLabels } from '../image-labels.ts' +import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -25,8 +23,8 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined - /** Session-authorized durable image loader. */ - loadImage?: ImageLoader + /** Render consecutive image blocks through the attachment slot. */ + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -35,9 +33,8 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, loadImage, mentions, t, + blocks, streaming, interrupted, renderMessageImages, mentions, t, }: AssistantMarkdownProps) { - const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) @@ -82,7 +79,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ group.push(next) i += 1 } - rendered.push() + rendered.push( + + {renderMessageImages({ + images: group.map(({ attachment }) => ({ attachment })), + align: 'start', + })} + , + ) break } // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx index 72e8a6ae28..850036f0cd 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx @@ -4,7 +4,7 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx' /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, useTurnData, openFile, loadImage, fileMentions, t, + node, useTurnData, openFile, renderMessageImages, fileMentions, t, }: ChatNodeViewProps<'assistant-step'>) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' @@ -25,7 +25,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({ blocks={data.blocks} streaming={data.status === 'running'} interrupted={data.status === 'interrupted'} - loadImage={loadImage} + renderMessageImages={renderMessageImages} mentions={mentions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx index f3343a183f..bc9c96fd41 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -18,7 +18,7 @@ type RoutedChatNodeOwner = { /** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, - loadImage, fileMentions, useSession, renderSlot, t, + renderMessageImages, fileMentions, useSession, renderSlot, t, }: ChatNodeSeatProps) { const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) const routedNode = node as ChatNode | undefined @@ -30,9 +30,11 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ openFile, inspectCall, forkAt, - loadImage, + renderMessageImages, fileMentions, - }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions]) + }, [ + node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions, + ]) if (routedNode === undefined || owner === null) return null // Runtime dispatch owns the correlation: every Node's discriminant is the // keyed-slot entry passed alongside that same Node. TypeScript does not diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 58e63b312f..4d9df510e1 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -12,10 +12,10 @@ // ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool // lifecycle updates replace only their own row without remounting it. -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' @@ -164,6 +164,10 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) + const renderMessageImages = useCallback( + owner => renderSlot('conversation.message.images', { ...owner, loadImage }), + [loadImage, renderSlot], + ) const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) const listRef = useRef(null) @@ -389,7 +393,7 @@ export function ChatView({ openFile={openFile} inspectCall={inspectCall} forkAt={forkAt} - loadImage={loadImage} + renderMessageImages={renderMessageImages} fileMentions={fileMentions} renderSlot={renderSlot} t={t} @@ -402,7 +406,12 @@ export function ChatView({ wait, tool execution, streaming) so it never flickers per step. */} {running && } {pendingSteering.map(item => ( - + ))}
{!atBottom && ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 00b5110d68..ecb0d11caa 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -9,9 +9,7 @@ import type { ModelRetryNode, TurnErrorNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' -import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' -import { messageImageLabels } from '../image-labels.ts' +import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' @@ -177,10 +175,10 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, imageLoader, actions, pending = false, t, + content, renderMessageImages, actions, pending = false, t, }: { content: readonly unknown[] - imageLoader: ImageLoader + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ @@ -193,7 +191,7 @@ function UserStyleBubble({ return (
- + {renderMessageImages({ images, align: 'end' })} {showBubble &&
{projectUserText(text)} {rest.map((block, i) => )} @@ -210,16 +208,15 @@ function UserStyleBubble({ * @param props - Pending message content and conversation translator. * @returns the pending steering bubble. */ -export function PendingSteeringBubble({ content, loadImage, t }: { +export function PendingSteeringBubble({ content, renderMessageImages, t }: { content: readonly unknown[] - loadImage?: ImageLoader + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] t: ChatViewSlotProps['t'] }): ReactNode { - const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) return ( ( @@ -236,13 +233,13 @@ export function PendingSteeringBubble({ content, loadImage, t }: { /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ - node, loadImage, t, + node, renderMessageImages, t, }: ChatNodeViewProps<'user' | 'steering'>) { const data = node.data return ( ( void + /** Remove one draft image through the conversation service. */ + onRemoveImage: (id: DraftAttachmentId) => void + /** Display-ready limits for the drop invitation. */ + dropLimits?: { readonly count: number; readonly size: string } | undefined +} + +/** Historical image group handed to the optional attachment presentation plugin. */ +export interface MessageImagesOwnerProps { + /** Consecutive image blocks rendered as one gallery. */ + images: readonly { readonly attachment: ImageAttachmentRef }[] + /** Session-authorized durable image loader. */ + loadImage: (attachment: ImageAttachmentRef) => Promise + /** Message-side alignment. */ + align: 'start' | 'end' +} + +/** Slot-backed renderer used by chat nodes without importing an attachment implementation. */ +export type RenderMessageImages = (owner: Omit) => ReactNode + declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** @@ -83,6 +110,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { hookContext: string inject: ChatNodeTurnDataInjected } + /** Optional renderer for one consecutive group of durable message images. */ + 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } /** * The chat view's per-command row hole: keyed dispatch on the command * name (`command/run.name`; a run-less cross-window node has none and @@ -199,6 +228,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * command face through its own inject. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } + /** Optional draft-image rail, drop target, and preview surface inside the composer. */ + 'conversation.input.attachments': { + kind: 'single' + scope: 'session-maybe' + owner: ComposerAttachmentsOwnerProps + } /** * The named plan-status seat in the composer tool row, immediately right * of the access-mode control — one occupant, so taking it means rendering @@ -361,8 +396,8 @@ export interface ChatNodeOwnerProps { openFile: (path: string) => void inspectCall: (callId: CallId) => void forkAt: (seq: number) => void - /** Resolve a session-authorized historical image for inline display. */ - loadImage: (attachment: ImageAttachmentRef) => Promise + /** Render a historical image group through the attachment slot. */ + renderMessageImages: RenderMessageImages fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } @@ -544,7 +579,9 @@ export interface InputControlOwnerProps { /** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> - & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> + & PropsRenderSlots< + 'conversation.input.attachments' | 'conversation.input.plan' | 'conversation.input.model' + > & InjectFace & PropsLocale<'conversation'> @@ -709,9 +746,17 @@ export interface ChatViewInjected { /** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'> + PropsRuntime<'conversation.view'> + & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'> & PropsStore & ChatViewInjected & PropsLocale<'conversation'> +/** Full props of the attachment plugin's composer entry. */ +export type ComposerAttachmentsProps = + PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'> + +/** Full props of the attachment plugin's message-gallery entry. */ +export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'> + /** * Injected share of the details slot: the panel is otherwise a pure reader of * the shared chat store, but its close button is a layout orchestration call. diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index bed6f5c89a..e322755473 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -1,10 +1,5 @@ -/** Bridges the `conversation` locale namespace to the zero-cordis attachment - * atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no - * application state; owners resolve every string). */ +/** Attachment error and limit copy owned by the conversation input flow. */ -import type { - AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels, -} from '@deepseek-ai/dsh-client-ui-attachment' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ConversationKey } from './locales.ts' @@ -56,61 +51,3 @@ export function attachmentErrorText( } return t('image.sendFailed', { reason }) } - -/** - * Resolve the original-image lightbox strings. - * @param t - the conversation-namespace translate. - * @returns the lightbox dialog and close-control labels. - */ -export function lightboxLabels(t: Translate): ImageLightboxLabels { - return { dialog: t('image.preview'), close: t('image.closePreview') } -} - -/** - * Resolve the chat-history image strings. - * @param t - the conversation-namespace translate. - * @returns the message-image labels including the forwarded lightbox strings. - */ -export function messageImageLabels(t: Translate): MessageImageLabels { - return { - image: t('image.label'), - open: t('image.openOriginal'), - openNamed: label => t('image.openOriginalLabel', { label }), - loading: t('image.loading'), - loadFailed: t('image.loadFailed'), - lightbox: lightboxLabels(t), - } -} - -/** - * Resolve the full-page drop overlay strings. - * @param t - the conversation-namespace translate. - * @param accepting - whether drops are currently accepted. - * @param limits - per-message limits for the desc line, when known. - * @returns the overlay title, with the limits desc while accepting. - */ -export function dropOverlayLabels( - t: Translate, - accepting: boolean, - limits?: { count: number; size: string }, -): DropOverlayLabels { - if (!accepting) return { title: t('image.dropBlocked') } - return { - title: t('image.dropTitle'), - desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }), - } -} - -/** - * Resolve the composer draft-image rail strings. - * @param t - the conversation-namespace translate. - * @returns the rail group, open-tooltip, and paging-arrow labels. - */ -export function attachmentRailLabels(t: Translate): AttachmentRailLabels { - return { - group: t('image.pending'), - open: t('image.openOriginal'), - scrollLeft: t('image.scrollLeft'), - scrollRight: t('image.scrollRight'), - } -} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 0574aa8375..4a8b27acbb 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -30,10 +30,10 @@ export type { export type { ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, - ComposerAttachment, ComposerChainProps, ConversationInjected, + ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, - TurnTailOwnerProps, UseChatNodeTurnData, + MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, TurnTailOwnerProps, UseChatNodeTurnData, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 6a2eb5fdf4..6635322a5a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -122,15 +122,6 @@ padding: 10px 12px 0; } -/* Rail seat: the card's top padding (10px) plus this 4px matches DeepSeek - Chat's spacing above the thumbnails; the card's 12px flex gap owns the space - below. The rail itself (arrows, hidden scrollbar, card geometry) is the - ui-attachment atom's. */ -.attachments { - min-width: 0; - padding: 4px 12px 0; -} - /* Floating overlay anchor (menu / popupSelect shell): entries position themselves against the card (bottom: 100% + gap); closed entries render null. */ .overlayAnchor { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 000174f513..501001215d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -12,8 +12,6 @@ import clsx from 'clsx' import { IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import { AttachmentRail, DropOverlay, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment' -import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' @@ -23,12 +21,10 @@ import type {} from '@deepseek-ai/dsh-goal/client' // wire types: apiproxy's sessions contract declares it, and client-runtime's // api-remotes import already places it in every client program. import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' -import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts' +import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' -import { - attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels, -} from '../image-labels.ts' +import { attachmentErrorText, imageSizeText } from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' @@ -37,11 +33,6 @@ import css from './InputBar.module.css' /** Decoration product of the no-session state (no machine, empty draft). */ const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null } -/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */ -interface ComposerRailItem extends AttachmentRailItem { - attachment: ComposerAttachment -} - export type InputBarProps = ComposerBarProps export function InputBar({ @@ -74,8 +65,6 @@ export function InputBar({ [draftImages, input?.imageIds], ) const empty = draft.trim() === '' && attachments.length === 0 - const [preview, setPreview] = useState(null) - const [dragActive, setDragActive] = useState(false) // Transient error banner (image-intake rejections and prompt failures): the // seq keys the Toast so an identical repeated message restarts the // hold-then-fade cycle instead of silently reusing the faded one. @@ -104,7 +93,6 @@ export function InputBar({ }, [promptError, showToast, t, imageLimits]) const inputRef = useRef(null) const cardRef = useRef(null) - const dragDepthRef = useRef(0) const scrollRef = useRef(null) const mirrorRef = useRef(null) const safari = useMemo(() => isSafariBrowser(navigator), []) @@ -168,11 +156,6 @@ export function InputBar({ safariNativeShrinkRef.current = false if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current) }, [draft, safari]) - - useEffect(() => { - if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) - }, [attachments, preview]) - // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does // not act. @@ -464,74 +447,7 @@ export function InputBar({ if (rejected !== null) showToast(rejected) }, [addImages, attachments, imageLimits, showToast, t]) - // Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live - // on the document so a drop anywhere over the window adds images, not only - // over the composer card. Safe as document-level state: the composer-bar - // slot is `kind: 'single'`, so at most one bar is mounted to bind these. - // Text drags carry no 'Files' type and pass through untouched, keeping the - // native drop-text-into-textarea path. The overlay layer itself is - // pointer-inert, so it never disturbs the enter/leave count. const canAcceptDrop = !locked && !machineBusy && addImages !== undefined - useEffect(() => { - const hasFiles = (event: globalThis.DragEvent): boolean => - event.dataTransfer?.types.includes('Files') ?? false - const reset = (): void => { - dragDepthRef.current = 0 - setDragActive(false) - } - const onDragEnter = (event: globalThis.DragEvent): void => { - if (!hasFiles(event)) return - event.preventDefault() - dragDepthRef.current += 1 - setDragActive(true) - } - const onDragOver = (event: globalThis.DragEvent): void => { - if (!hasFiles(event) || event.dataTransfer === null) return - event.preventDefault() - event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none' - } - const onDragLeave = (event: globalThis.DragEvent): void => { - if (!hasFiles(event)) return - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - if (dragDepthRef.current === 0) setDragActive(false) - // Leaving through the viewport edge does not balance the count on every - // engine; a page-root leave at the border means the drag left the window. - const leavingViewport = event.clientX <= 0 || event.clientY <= 0 - || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight - if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset() - } - const onDrop = (event: globalThis.DragEvent): void => { - if (!hasFiles(event)) return - event.preventDefault() - reset() - if (!canAcceptDrop) return - intakeImages([...(event.dataTransfer?.files ?? [])]) - } - document.addEventListener('dragenter', onDragEnter) - document.addEventListener('dragover', onDragOver) - document.addEventListener('dragleave', onDragLeave) - document.addEventListener('drop', onDrop) - window.addEventListener('dragend', reset) - return () => { - document.removeEventListener('dragenter', onDragEnter) - document.removeEventListener('dragover', onDragOver) - document.removeEventListener('dragleave', onDragLeave) - document.removeEventListener('drop', onDrop) - window.removeEventListener('dragend', reset) - } - }, [canAcceptDrop, intakeImages]) - - const closePreview = useCallback(() => { setPreview(null) }, []) - - // Rail thumbnails with their strings resolved here: the attachment atoms are - // zero-cordis and read no locale. - const railItems = useMemo(() => attachments.map(attachment => ({ - id: attachment.id, - previewUrl: attachment.previewUrl, - alt: attachment.file.name || t('image.pending'), - removeLabel: t('image.remove', { name: attachment.file.name }), - attachment, - })), [attachments, t]) const onSelect = (e: React.SyntheticEvent): void => { // Any caret/selection gesture ends a live paste attempt (the machine @@ -655,15 +571,6 @@ export function InputBar({ return (
- {dragActive && ( - - )} {toast !== null && ( {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} - {railItems.length > 0 && ( -
- { setPreview(item.attachment) }} - onRemove={(item) => { removeImage?.(item.attachment.id) }} - /> -
- )} + {renderSlot('conversation.input.attachments', { + attachments, + canAcceptDrop, + onAddImages: intakeImages, + onRemoveImage: (id) => { removeImage?.(id) }, + dropLimits: imageLimits === undefined ? undefined : { + count: imageLimits.maxImagesPerMessage, + size: imageSizeText(imageLimits.maxImageBytes), + }, + })} {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14 @@ -810,14 +717,6 @@ export function InputBar({
- {preview !== null && ( - - )} {footer}
) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx index 67a213a98e..2164fcd059 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx @@ -21,7 +21,7 @@ import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -42,6 +42,7 @@ afterEach(() => { // Mirrors the real lookup chain (conversation namespace, then common). const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) +const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null const RETRY_ID = 'retry-fixture' as Extract['retryId'] interface MessageItemProps { @@ -949,7 +950,12 @@ describe('useCalendarDay boundary refresh', () => { describe('small branch tails', () => { it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { const view = render( - , + , ) expect(view.getByText('one-liner')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx index b0ce994f44..c6e3563e3e 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx @@ -13,6 +13,7 @@ import { zh } from '../src/client/locales.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) +const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null afterEach(cleanup) @@ -31,13 +32,20 @@ describe('tails', () => { { kind: 'other', block: { type: 'mystery' } }, ]} streaming + renderMessageImages={renderMessageImages} />, ) expect(view.getByText('Think')).toBeTruthy() expect(view.getByText('thinking hard')).toBeTruthy() expect(view.getByText(/未知内容块/)).toBeTruthy() const stopped = render( - , + , ) expect(stopped.getByText('已停止')).toBeTruthy() }) @@ -50,10 +58,13 @@ describe('tails', () => { t={t} blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]} streaming={false} + renderMessageImages={renderMessageImages} />, ) expect(empty.container.firstChild).toBeNull() - const blank = render() + const blank = render( + , + ) expect(blank.container.firstChild).toBeNull() }) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx index 588ca52c43..a934efb727 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx @@ -21,6 +21,7 @@ import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) +const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ class ResizeObserverStub { @@ -64,6 +65,7 @@ describe('render branch tails', () => { t={t} blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]} streaming + renderMessageImages={renderMessageImages} />, ) // reasoning at index 0 with a later block: running is false → ok state. @@ -100,7 +102,12 @@ describe('render branch tails', () => { it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { const view = render( - , + , ) expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() }) diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx index bec1fa8ebe..5b01fc5712 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx @@ -1,14 +1,13 @@ // @vitest-environment jsdom -// The conversation-side bridge to the ui-attachment atoms: dictionary strings -// flow through image-labels into the gallery, and assistant images keep their -// block position between text blocks. +// Conversation-owned attachment errors and the message-image slot handoff. import { afterEach, describe, expect, it } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' +import { cleanup, render } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import type { RenderMessageImages } from '../src/client/contract/slots.ts' import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts' import { en, zh } from '../src/client/locales.ts' @@ -26,6 +25,21 @@ const attachment = { name: 'history.png', } +type MessageImagesRenderOwner = Parameters[0] + +function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { + return (owner) => { + calls.push(owner) + return ( +
+ {owner.images.map(({ attachment: image }, index) => ( + {image.name} + ))} +
+ ) + } +} + describe('attachment rejection copy', () => { const limits = { maxImageBytes: 5 * 1024 * 1024, @@ -60,42 +74,24 @@ describe('attachment rejection copy', () => { }) }) -describe('assistant images through the label bridge', () => { - it('resolves zh dictionary strings and opens the lightbox on a single click', async () => { +describe('assistant image slot handoff', () => { + it('passes one image group and its message alignment to the renderer', () => { + const calls: MessageImagesRenderOwner[] = [] const view = render( Promise.resolve('blob:history')} + renderMessageImages={imageRenderer(calls)} />, ) - const frame = await view.findByRole('button', { name: 'history.png,点击查看原图' }) - expect(frame.getAttribute('title')).toBe('查看原图') - await view.findByAltText('history.png') - fireEvent.click(frame) - expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() - fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) - expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + expect(view.getByTestId('message-images').getAttribute('data-align')).toBe('start') + expect(calls).toHaveLength(1) + expect(calls[0]?.images).toEqual([{ attachment }]) }) - it('resolves the active English dictionary', async () => { - const view = render( - Promise.resolve('blob:history')} - />, - ) - const frame = await view.findByRole('button', { name: 'history.png, click to view original' }) - await view.findByAltText('history.png') - fireEvent.click(frame) - expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() - expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() - }) - - it('merges consecutive image blocks into one tiled gallery, split by text', async () => { + it('merges consecutive image blocks into one group and splits groups at text', () => { + const calls: MessageImagesRenderOwner[] = [] const view = render( { { kind: 'image', attachment }, ]} streaming={false} - loadImage={() => Promise.resolve('blob:grouped')} + renderMessageImages={imageRenderer(calls)} />, ) - await view.findAllByAltText('history.png') - const galleries = view.container.querySelectorAll('[data-align="start"]') + const galleries = view.getAllByTestId('message-images') expect(galleries).toHaveLength(2) - expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2) - expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1) + expect(galleries.map(gallery => gallery.getAttribute('data-count'))).toEqual(['2', '1']) + expect(calls.map(call => call.images.length)).toEqual([2, 1]) }) - it('keeps assistant images at their original position between text blocks', async () => { + it('keeps the renderer output at the image block position between text blocks', () => { + const calls: MessageImagesRenderOwner[] = [] const view = render( { { kind: 'text', text: 'after' }, ]} streaming={false} - loadImage={() => Promise.resolve('blob:middle')} + renderMessageImages={imageRenderer(calls)} />, ) - const image = await view.findByAltText('history.png') + const image = view.getByTestId('message-images') const before = view.getByText('before') const after = view.getByText('after') expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) diff --git a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx b/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx index 62e6ac7848..551a286a88 100644 --- a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { zh } from '../src/client/locales.ts' let nextAnimationFrameId = 1 @@ -37,6 +37,7 @@ afterEach(() => { }) const t = makeTranslate(zh, commonZh) +const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null describe('ReasoningRow', () => { it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => { @@ -45,6 +46,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]} streaming + renderMessageImages={renderMessageImages} />, ) expect(view.getByText('运行中')).toBeTruthy() @@ -59,6 +61,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]} streaming + renderMessageImages={renderMessageImages} />, ) expect(summary.scrollLeft).toBe(0) @@ -73,6 +76,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]} streaming={false} + renderMessageImages={renderMessageImages} />, ) flushAnimationFrames(3) @@ -88,6 +92,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]} streaming={false} + renderMessageImages={renderMessageImages} />, ) const row = view.getByRole('button') @@ -106,6 +111,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]} streaming={false} + renderMessageImages={renderMessageImages} />, ) fireEvent.click(view.getByText('Think')) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 79e9e3dc7c..a88ba5c8c1 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../ui-slots" }, - { - "path": "../ui-attachment" - }, { "path": "../ui-primitives" }, From f37bc082c551cb4fa5569e157186323c47e8600f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:39:18 +0800 Subject: [PATCH 06/31] refactor(client): move web rendering into a dynamic plugin --- .../2026-07-19-gui-web-client-architecture.md | 18 +- ...26-07-19-gui-web-client-architecture.zh.md | 18 +- ...26-07-22-slot-type-chain-implementation.md | 4 +- ...07-22-slot-type-chain-implementation.zh.md | 4 +- .../2026-07-23-client-plugin-loading-model.md | 19 +- ...26-07-23-client-plugin-loading-model.zh.md | 19 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...-render-and-attachment-ownership.i18n.yaml | 6 + ...-client-render-and-attachment-ownership.md | 43 ++++ ...ient-render-and-attachment-ownership.zh.md | 43 ++++ ...8-themed-scrollbars-and-reserved-gutter.md | 2 +- ...hemed-scrollbars-and-reserved-gutter.zh.md | 2 +- .../2026-08-10-pre-plugin-theme-bootstrap.md | 6 +- ...026-08-10-pre-plugin-theme-bootstrap.zh.md | 6 +- ...-08-11-web-attachment-display-alignment.md | 6 +- ...-11-web-attachment-display-alignment.zh.md | 6 +- ...026-07-26-web-syntax-highlighting-shiki.md | 2 +- ...-07-26-web-syntax-highlighting-shiki.zh.md | 2 +- apps/web/src/main.ts | 2 +- apps/web/tests/assembled-boot.ts | 10 +- apps/web/vite.config.ts | 4 +- knip.json | 12 +- packages/bundle/web-app/cordis.patch.yml | 6 + packages/bundle/web-app/package.json | 2 + packages/client/README.md | 4 +- packages/client/README.zh.md | 4 +- .../client/render-service/README.i18n.yaml | 6 + packages/client/render-service/README.md | 19 ++ packages/client/render-service/README.zh.md | 19 ++ packages/client/render-service/package.json | 72 ++++++ .../src/client}/DocumentTitle.tsx | 8 +- .../src => render-service/src/client}/app.tsx | 20 +- .../client/render-service/src/client/index.ts | 44 ++++ packages/client/render-service/src/index.ts | 4 + .../client/render-service/src/invariant.ts | 30 +++ .../tests/app.client.spec.tsx | 15 +- .../tests/document-title.client.spec.tsx | 7 +- .../tests/render-service.client.spec.tsx | 65 +++++ packages/client/render-service/tsconfig.json | 27 ++ .../client/render-service/tsdown.config.ts | 3 + packages/client/tsdown.client.ts | 80 ++++-- packages/client/ui-attachment/README.md | 6 +- packages/client/ui-attachment/README.zh.md | 6 +- .../src/client/ComposerAttachments.tsx | 21 +- .../client/ui-attachment/src/client/labels.ts | 26 +- .../tests/attachment-rail.client.spec.tsx | 9 + .../composer-attachments.client.spec.tsx | 160 ++++++++++++ .../tests/message-image.client.spec.tsx | 56 +++++ .../ui-attachment/tests/plugin.client.spec.ts | 38 +++ .../tests/chat-branch-tails.client.spec.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 128 ++++------ .../src/client/settings-store.ts | 1 + .../tests/browser-plugin.client.spec.ts | 2 + .../ui-settings-models/src/client/store.ts | 1 + .../tests/apply.client.spec.ts | 4 +- packages/client/ui-settings/README.md | 4 +- packages/client/ui-settings/README.zh.md | 4 +- .../client/ui-settings/src/client/schema.ts | 51 +++- .../ui-settings/tests/plugin.client.spec.ts | 4 +- .../ui-settings/tests/schema.client.spec.ts | 101 ++++++++ .../tests/settings-scope.client.spec.ts | 29 ++- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- packages/client/ui-theme/package.json | 2 - packages/client/ui-theme/src/client/index.ts | 2 + packages/client/ui-theme/src/client/styles.ts | 31 +++ packages/client/ui-theme/src/css-modules.d.ts | 5 + .../tests/client-styles.client.spec.ts | 32 +++ packages/client/ui-theme/tsdown.config.ts | 5 - .../tests/workflow-run.client.spec.tsx | 2 +- packages/client/web/README.md | 9 +- packages/client/web/README.zh.md | 9 +- packages/client/web/package.json | 8 +- packages/client/web/src/AppRoot.module.css | 66 ----- packages/client/web/src/AppRoot.tsx | 60 ----- packages/client/web/src/app-shell.ts | 50 ---- packages/client/web/src/base.css | 11 +- packages/client/web/src/boot-page.module.css | 80 ++++++ packages/client/web/src/boot-page.ts | 75 ++++++ packages/client/web/src/boot.ts | 147 +++++++++++ packages/client/web/src/boot.tsx | 238 ------------------ packages/client/web/src/index.ts | 17 +- packages/client/web/src/loader-status.ts | 82 +----- packages/client/web/src/platform.ts | 2 - packages/client/web/src/seed.ts | 4 - .../client/web/tests/app-root.client.spec.tsx | 76 ------ .../web/tests/app-shell.client.spec.tsx | 61 ----- .../web/tests/base-styles.client.spec.ts | 54 +--- .../client/web/tests/boot-page.client.spec.ts | 53 ++++ packages/client/web/tsconfig.json | 8 +- packages/client/web/tsdown.config.ts | 2 +- pnpm-lock.yaml | 131 +++++----- scripts/client-bundle-css.spec.ts | 60 ++++- scripts/gen-cordis-catalog.ts | 3 +- .../verify-package-readme-model-experience.ts | 2 +- tsconfig.base.json | 5 +- tsconfig.client.json | 2 +- 98 files changed, 1675 insertions(+), 1049 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md create mode 100644 .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md create mode 100644 packages/client/render-service/README.i18n.yaml create mode 100644 packages/client/render-service/README.md create mode 100644 packages/client/render-service/README.zh.md create mode 100644 packages/client/render-service/package.json rename packages/client/{web/src => render-service/src/client}/DocumentTitle.tsx (73%) rename packages/client/{web/src => render-service/src/client}/app.tsx (53%) create mode 100644 packages/client/render-service/src/client/index.ts create mode 100644 packages/client/render-service/src/index.ts create mode 100644 packages/client/render-service/src/invariant.ts rename packages/client/{web => render-service}/tests/app.client.spec.tsx (73%) rename packages/client/{web => render-service}/tests/document-title.client.spec.tsx (83%) create mode 100644 packages/client/render-service/tests/render-service.client.spec.tsx create mode 100644 packages/client/render-service/tsconfig.json create mode 100644 packages/client/render-service/tsdown.config.ts create mode 100644 packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx create mode 100644 packages/client/ui-attachment/tests/plugin.client.spec.ts create mode 100644 packages/client/ui-settings/tests/schema.client.spec.ts create mode 100644 packages/client/ui-theme/src/client/styles.ts create mode 100644 packages/client/ui-theme/tests/client-styles.client.spec.ts delete mode 100644 packages/client/web/src/AppRoot.module.css delete mode 100644 packages/client/web/src/AppRoot.tsx delete mode 100644 packages/client/web/src/app-shell.ts create mode 100644 packages/client/web/src/boot-page.module.css create mode 100644 packages/client/web/src/boot-page.ts create mode 100644 packages/client/web/src/boot.ts delete mode 100644 packages/client/web/src/boot.tsx delete mode 100644 packages/client/web/tests/app-root.client.spec.tsx delete mode 100644 packages/client/web/tests/app-shell.client.spec.tsx create mode 100644 packages/client/web/tests/boot-page.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 070b857f14..efe3688162 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -22,23 +22,23 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon │ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │ │ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │ │ │ │ │ conversation/trajectory(fetch bundle,按需) │ -└────────────────────────────────┘ │ ├ app-shell 伪行(壳内静态注册,同一治理) │ +└────────────────────────────────┘ │ ├ render-service(fetch bundle,React 根) │ │ └ session scope ×N(观看驱动,惰性建) │ - │ React: loading 页 → settled → 整 UI 一次成型 │ + │ DOM loading 页 → settled → React UI 一次成型 │ └────────────────────────────────────────────────────┘ ``` ## The client cordis tree and the loading chain -The loading chain — the two package kinds (plain vs dsh.client plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dsh.client` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `