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 01/26] 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 02/26] 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 03/26] 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 04/26] 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 05/26] 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 `