Merge remote-tracking branch 'origin/master' into docs/post-v3-release-proofreading

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
#	README.i18n.yaml
#	README.zh.md
#	scripts/snapshots/translation-prompt-v4/request-response.expected.json
This commit is contained in:
xjt
2026-08-12 16:45:48 +08:00
254 changed files with 5437 additions and 517 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
README.md: cc903af7204ca715c6c7931cfe44823d4d5fc71e
README.zh.md: fe34b8774c9864cef442ff8a58f22f541d40768a
README.md: 288c63c9f43654dfec428a6a8955dc537efe81a6
README.zh.md: 1fd599b08ecc1b4ae1dba738ce8946aa4eab5946

View File

@@ -6,7 +6,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.

View File

@@ -6,8 +6,7 @@
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence并为 TypeRT `agent``session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
当前 Client 组合挂载 Goal Remote 贡献。该组合卸载时Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`。该组合卸载时Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。

View File

@@ -64,6 +64,7 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
@@ -78,6 +79,7 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",

View File

@@ -3,11 +3,14 @@
import type { Context } from '@deepseek-ai/cordis'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
export type { ApiRemoteForwardedEvent } from '../types.ts'
@@ -54,7 +57,7 @@ export const inject = ['remote']
export async function apply(ctx: Context): Promise<() => Promise<void>> {
const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [commandsRemote, goalsRemote]) {
for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote]) {
disposers.push(await ctx.remote.$mount(contribution))
}
} catch (error) {

View File

@@ -26,6 +26,9 @@
{
"path": "../../goal/goal"
},
{
"path": "../../host/plugin-inventory"
},
{
"path": "../../interaction/commands"
},

View File

@@ -14,9 +14,9 @@ export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
/** Default maximum encoded bytes for one image. */
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
/** Default maximum images in one prompt. */
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20
/** Default maximum aggregate image bytes in one prompt. */
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024
/** Default maximum intrinsic pixels for one image. */
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000

View File

@@ -14,6 +14,7 @@ import LocalAttachmentStore, {
describe('local attachment service', () => {
it('resolves every omitted admission limit explicitly', () => {
const service = new LocalAttachmentStore(new Context(), {})
expect(DEFAULT_MAX_IMAGE_BYTES).toBe(5 * 1024 * 1024)
expect(service.imageLimits).toEqual({
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,

View File

@@ -80,6 +80,10 @@
- id: directory-picker
name: '@deepseek-ai/dsh-host-directory-picker-auto'
# Read-only projection of current Loader entries for trusted client RPCs.
- id: plugin-inventory
name: '@deepseek-ai/dsh-host-plugin-inventory'
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. The base layer's agent-default-model service owns the default model.
- id: api-gateway
@@ -172,6 +176,9 @@
- id: ui-models
name: '@deepseek-ai/dsh-client-ui-models'
- id: ui-plugins
name: '@deepseek-ai/dsh-client-ui-plugins'
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'

View File

@@ -62,6 +62,7 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
"@deepseek-ai/dsh-client-ui-plugins": "workspace:^",
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
"@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^",
@@ -86,6 +87,7 @@
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: 75abe408952ed66dcc237ce489e417f61159bcc3
README.zh.md: 237735b6b9b0b42af2e454e231025da070f5a3e3
README.md: 236531281c17ef982982e97caad99491584bd0b5
README.zh.md: 73bc3e31c90a4f12c4c5f11e9fd0552601dcc7a6

View File

@@ -41,6 +41,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. |
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.

View File

@@ -41,6 +41,7 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 |
每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: a82bb55ab65df2732ad16248d2cc9aa15b60e94d
README.zh.md: cc9e4e01bb0dec278cf162ae442154d11c76aa85
README.md: d3727df981ecbc022345def48b38fbc879e29cb2
README.zh.md: 5e632bf6d7135bda60178f0699691aff2d5623db

View File

@@ -23,3 +23,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.
- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 160 MiB, sized for the default 100 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits.

View File

@@ -23,3 +23,4 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
## 已知限制与暂缓事项
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent并增加首次打开的延迟没有仅从持久化读取的路径。
- **`/api` 桥把每个请求体整体缓冲在内存里**`maxRequestBodyBytes`(默认 160 MiB按默认 100 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。

View File

@@ -979,6 +979,18 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
values['contextPressure'] = contextPressureOf(log)
// Always present (token-meter composed): heuristic request composition.
values['contextBreakdown'] = contextBreakdownOf(log)
// Always present (attachment service composed): the deployment image
// limits, constant per boot (mirrors the attachment-local defaults).
// Deliberate host divergence: the real gateway never pushes an imageLimits
// change frame (constant unit), but the fixture's uniform baseline replay
// frames every key here, incidentally exercising higher-seq-wins.
values['imageLimits'] = {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
}
return values
}

View File

@@ -5,6 +5,12 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Default carrier cap for all HTTP RPC bodies: sized for the default
* aggregate image limit (100 MiB) after base64 expansion plus envelope
* headroom (~134.3 MiB required), rounded up for slack. The bridge buffers
* each body in memory, so this cap is also the per-request resident bound. */
export const DEFAULT_MAX_REQUEST_BODY_BYTES = 160 * 1024 * 1024
/** Transport-independent request handler consumed by the Host HTTP bridge. */
export interface FetchHandler {
/**
@@ -27,7 +33,7 @@ export async function bridge(
req: IncomingMessage,
res: ServerResponse,
apiHandler: FetchHandler,
maxRequestBodyBytes = 32 * 1024 * 1024,
maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES,
): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:

View File

@@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/dsh-attachment'
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
import { HostConnectionService } from './rpc-host.ts'
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
@@ -42,8 +42,6 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi
)
}
}
/** Default carrier cap for all HTTP RPC bodies. */
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
export const inject = ['httpServer']

View File

@@ -164,6 +164,13 @@ describe('createFixtureApi', () => {
toolsTokens: 0,
messageTokens: 0,
},
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
},
} },
})
})
@@ -374,10 +381,14 @@ describe('createFixtureApi', () => {
value: { systemTokens: 0, toolsTokens: 0 },
})
expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[10]?.rpcId).toBe(first[10]?.rpcId)
expect(first[9]?.payload).toMatchObject({
type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits',
value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 },
})
expect(first[10]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[10]?.rpcId).toBe(first[10]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[11]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[11]?.rpcId).toBe(first[11]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
README.md: 9fab9c23b958606030b1e87fcbfa45130c980947
README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5
README.md: 65db3f03b3ef174d12de75786e197da4712c4de1
README.zh.md: 2b714e880912d6dc097ce73aca4286a674bdade5

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), the original-image lightbox (`ImageLightbox`), and the full-page drop overlay (`DropOverlay`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
## Attachment rail
@@ -10,7 +10,11 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
## Message images and the lightbox
`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount.
`MessageImage` renders one durable history image, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). Sizing follows DeepSeek Chat: a message's lone image (`variant="single"`) renders at 240px on its longer edge with the displayed aspect ratio clamped to [0.25, 4] — the overflow is cropped by `object-fit: cover`, anchored to the top of very tall images and the left of very wide ones — and never upscales past its natural size; an image among several (`variant="tile"`) is a fixed 64px square. `ImageGallery` wraps a message's images in one aligned wrapping flex group (`end` for user messages, `start` for assistant messages), picks the variant from the image count, and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview over the shared dialog mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`, painted on its own layer so the blur never touches the previewed image) that closes on Escape, a mask press, or its close control, and restores focus to its opener on unmount.
## Drop overlay
`DropOverlay` is the full-viewport invitation shown while a file drag is over the page: illustration, title, and a limits line while drops are accepted (`disabled` swaps the blocked illustration and hides the limits line). The layer is pointer-inert — the owner's document-level drag listeners keep the enter/leave count and decide accept/reject; the overlay only shows state. It portals to the body like the lightbox.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 附件原子组件(零 cordis输入框草稿图片栏`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
纯 React 附件原子组件(零 cordis输入框草稿图片栏`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`原图灯箱(`ImageLightbox`与整页拖放遮罩(`DropOverlay`。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
## 附件栏
@@ -10,7 +10,11 @@
## 消息图片与灯箱
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px经持有方的 `ImageLoader` 加载会话授权 URL加载失败渲染显式重试按钮加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭卸载时将焦点还给打开者。
`MessageImage` 渲染一张持久化历史图片,经持有方的 `ImageLoader` 加载会话授权 URL加载失败渲染显式重试按钮加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。尺寸规则对齐 DeepSeek Chat一条消息仅有的一张图`variant="single"`)长边 240px、展示宽高比钳制在 [0.25, 4] 之间——超出部分由 `object-fit: cover` 裁切,特别高的图锚定顶部、特别宽的图锚定左侧——且从不放大超过原始尺寸;多图中的一张(`variant="tile"`)为固定 64px 方块。`ImageGallery` 将一条消息的图片包为一个对齐的可换行弹性分组(用户消息 `end`,助手消息 `start`按图片数量选择 variant空列表不渲染。`ImageLightbox` 是文档级模态预览,铺在共享的对话框遮罩上(`--dsw-alias-bg-mask-1``--dsw-mask-blur`,画在独立图层上,模糊不会波及预览图本身),按 Escape、按下遮罩或点关闭按钮均可关闭卸载时将焦点还给打开者。
## 拖放遮罩
`DropOverlay` 是文件拖拽悬停页面时的全视口邀请层:插画、标题,接受拖放时再加一行上限说明(`disabled` 换为禁用插画并隐藏上限行)。该层不接收指针事件——持有方的 document 级拖拽监听器负责 enter/leave 计数和接受与否的判定;遮罩只呈现状态。与灯箱一样经 body portal 渲染。
## 模型体验

View File

@@ -0,0 +1,54 @@
/* Full-viewport drop invitation (DeepSeek Chat DragMask). pointer-events:
none — the layer is decoration; drag events must keep hitting the page so
the owner's enter/leave count stays balanced. The frosted sheet color is
the theme's drop-mask alias (dark override lives with the theme owner). */
.mask {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background-color: var(--dsw-alias-bg-mask-drop);
backdrop-filter: blur(10px);
animation: fade-in 160ms ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.mask {
animation: none;
}
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
margin-top: -3%;
padding: 0 40px;
color: var(--dsw-alias-label-primary);
text-align: center;
}
.illustration {
width: 115px;
height: 84px;
}
.title {
margin-top: 16px;
font: var(--dsw-font-l-20);
}
.desc {
margin-top: 16px;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
}

View File

@@ -0,0 +1,77 @@
import { createPortal } from 'react-dom'
import css from './DropOverlay.module.css'
/** Drop-overlay strings the owner resolves from its own locale namespace. */
export interface DropOverlayLabels {
/** Headline inviting the drop, or naming why it is unavailable. */
title: string
/** Limits line under the title; shown only while drops are accepted. */
desc?: string | undefined
}
/**
* Full-viewport invitation shown while a file drag is over the page
* (DeepSeek Chat's DragMask). Decoration only: `pointer-events: none` keeps
* drag targeting on the page below, so the owner's document-level listeners
* keep an accurate enter/leave count and own accept/reject. Rendered through
* a body portal for the same transformed-ancestor reason as the lightbox.
*
* @param props.disabled - drops are currently refused; renders the blocked
* illustration and drops the desc line.
* @param props.labels - resolved title and limits strings.
* @returns the overlay layer.
*/
export function DropOverlay({ disabled, labels }: {
disabled: boolean
labels: DropOverlayLabels
}) {
return createPortal(
<div className={css.mask} role="status">
<div className={css.wrap}>
<div className={css.illustration} aria-hidden="true">
{disabled ? <UploadDisabledIllustration /> : <UploadIllustration />}
</div>
<div className={css.title}>{labels.title}</div>
{!disabled && labels.desc !== undefined && <div className={css.desc}>{labels.desc}</div>}
</div>
</div>,
document.body,
)
}
/** Tilted photo-and-note cards (DeepSeek Chat upload illustration). */
const UploadIllustration = () => (
<svg width="115" height="84" viewBox="0 0 115 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#dshDropOverlayClip)">
<rect y="17.0742" width="44.1832" height="43.6431" rx="12" transform="rotate(-22.7338 0 17.0742)" fill="#9CE5ED" />
<rect x="73.4043" y="8.54297" width="43.7267" height="50.5284" rx="8" transform="rotate(17.403 73.4043 8.54297)" fill="#679EFE" />
<path d="M30.4917 28.1369L40.8865 33.4564L37.2232 34.9524L29.5302 31.0159L26.7919 39.2122L23.1285 40.7082L26.8287 29.6338L16.8967 24.5516L20.5601 23.0556L27.7902 26.7549L30.3639 19.052L34.0273 17.556L30.4917 28.1369Z" fill="white" />
<path d="M77.5088 26.3047L101.057 33.7966" stroke="white" strokeWidth="3" />
<path d="M72.2646 42.7871L86.3938 47.2823" stroke="white" strokeWidth="3" />
<path d="M74.8867 34.5469L98.4353 42.0388" stroke="white" strokeWidth="3" />
<rect x="31.583" y="38.6641" width="44.9157" height="44.3666" rx="12" transform="rotate(-0.134233 31.583 38.6641)" fill="#3964FE" />
<path d="M38.9521 73.0337C39.6129 71.7086 41.7113 66.0937 43.5113 61.1663C44.1607 59.3885 46.7484 59.3923 47.4591 61.1465C48.9728 64.8828 50.7969 68.6922 51.9988 69.1925C54.2946 70.1482 57.9854 59.3573 68.0064 70.1801" stroke="white" strokeWidth="3" />
<circle cx="60.6157" cy="52.247" r="4.38794" transform="rotate(22.5996 60.6157 52.247)" fill="white" />
</g>
<defs>
<clipPath id="dshDropOverlayClip">
<rect width="115" height="84" fill="white" />
</clipPath>
</defs>
</svg>
)
/** Greyed cards with a blocked badge (DeepSeek Chat disabled illustration). */
const UploadDisabledIllustration = () => (
<svg width="115" height="84" viewBox="0 0 115 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.6829 4.63701L11.0677 12.4368C4.95519 14.998 2.07624 22.0294 4.6374 28.1419L12.2285 46.259C14.7896 52.3715 21.8211 55.2505 27.9336 52.6893L46.5488 44.8895C52.6613 42.3283 55.5403 35.2969 52.9791 29.1844L45.388 11.0673C42.8269 4.9548 35.7954 2.07585 29.6829 4.63701Z" fill="#979DA6" />
<path d="M30.4915 28.1375L40.8863 33.4569L37.223 34.9529L29.53 31.0165L26.7917 39.2128L23.1283 40.7088L26.8285 29.6344L16.8965 24.5522L20.5599 23.0562L27.79 26.7555L30.3637 19.0526L34.0271 17.5566L30.4915 28.1375Z" fill="white" />
<path d="M107.496 19.2285L81.0381 10.9357C76.8221 9.61423 72.333 11.9607 71.0116 16.1768L60.6844 49.1246C59.363 53.3406 61.7095 57.8297 65.9255 59.1511L92.383 67.4439C96.599 68.7654 101.088 66.4189 102.41 62.2029L112.737 29.255C114.058 25.039 111.712 20.55 107.496 19.2285Z" fill="#979DA6" />
<path d="M77.5088 26.3047L101.057 33.7967" stroke="white" strokeWidth="3" />
<path d="M72.2646 42.7871L86.3938 47.2823" stroke="white" strokeWidth="3" />
<path d="M74.8867 34.5469L98.4353 42.0388" stroke="white" strokeWidth="3" />
<path d="M66.5798 30.1418L41.481 30.2006C33.5281 30.2193 27.0962 36.6815 27.1148 44.6343L27.172 69.0742C27.1907 77.0271 33.6529 83.459 41.6057 83.4404L66.7045 83.3816C74.6574 83.363 81.0894 76.9008 81.0707 68.9479L81.0135 44.5081C80.9949 36.5552 74.5327 30.1232 66.5798 30.1418Z" fill="#F59E0B" />
<path d="M54 70.7969C61.732 70.7969 68 64.5289 68 56.7969C68 49.0649 61.732 42.7969 54 42.7969C46.268 42.7969 40 49.0649 40 56.7969C40 64.5289 46.268 70.7969 54 70.7969Z" stroke="white" strokeWidth="3.5" />
<path d="M44 46.7969L64 66.7969" stroke="white" strokeWidth="3.5" strokeLinecap="round" />
</svg>
)

View File

@@ -5,10 +5,20 @@
display: grid;
place-items: center;
padding: 40px;
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
}
/* Same mask recipe as the Modal primitive and the settings dialog. A separate
layer, not a background on .backdrop: backdrop-filter there would blur the
previewed image and the close control along with the page. */
.mask {
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
.image {
position: relative;
max-width: min(100%, 1600px);
max-height: calc(100vh - 80px);
object-fit: contain;
@@ -21,6 +31,7 @@
position: fixed;
top: 20px;
right: 20px;
z-index: 1;
display: grid;
place-items: center;
width: 36px;
@@ -29,6 +40,5 @@
border-radius: 999px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
font-size: 24px;
cursor: pointer;
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './ImageLightbox.module.css'
/** Lightbox strings the owner resolves from its own locale namespace. */
@@ -51,10 +52,12 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
role="dialog"
aria-modal="true"
aria-label={labels.dialog}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
<IconCloseOutline16 size={16} />
</button>
</div>,
document.body,
)

View File

@@ -1,8 +1,8 @@
.gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: min(240px, 100%);
gap: 10px;
max-width: 100%;
}
.gallery[data-align='end'] {
@@ -29,11 +29,18 @@
cursor: zoom-in;
}
.frame[data-variant='tile'] {
width: 64px;
height: 64px;
min-width: 64px;
min-height: 64px;
}
.frame img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
}
.loading,
@@ -51,3 +58,12 @@
background: var(--dsw-alias-interactive-bg-hover-danger);
cursor: pointer;
}
/* A failed tile keeps the 64px grid cell instead of growing to its copy. */
.error[data-variant='tile'] {
width: 64px;
height: 64px;
padding: 4px;
overflow: hidden;
border-radius: 16px;
}

View File

@@ -23,18 +23,38 @@ export interface MessageImageLabels {
lightbox: ImageLightboxLabels
}
/** Display box for a lone image (DeepSeek Chat rule): long edge 240px with
* the rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by
* `object-fit: cover` — and never upscaled past the image's natural size. The
* crop anchor keeps the top of very tall images and the left of very wide
* ones, where the informative content usually starts. */
function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } {
const natural = attachment.width / attachment.height
const ratio = Math.min(4, Math.max(0.25, natural))
const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 }
const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height)
return {
width: Math.max(1, Math.round(box.width * scale)),
height: Math.max(1, Math.round(box.height * scale)),
objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
}
}
/**
* Compact history renderer with retryable loading and click-to-open original
* preview.
* preview. A lone image renders at its `singleFit` size; an image among
* several renders as a fixed 64px square tile.
*
* @param props.attachment - the durable image reference to load and bound.
* @param props.load - session-authorized URL loader.
* @param props.variant - `single` for a message's lone image, `tile` otherwise.
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
* @returns the bounded thumbnail button, or the retry control on failure.
*/
export function MessageImage({ attachment, load, labels }: {
export function MessageImage({ attachment, load, variant, labels }: {
attachment: ImageAttachmentRef
load: ImageLoader
variant: 'single' | 'tile'
labels: MessageImageLabels
}) {
const [src, setSrc] = useState<string | null>(null)
@@ -45,10 +65,10 @@ export function MessageImage({ attachment, load, labels }: {
const [attempt, setAttempt] = useState(0)
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
const fit = useMemo(
() => (variant === 'single' ? singleFit(attachment) : undefined),
[attachment, variant],
)
useEffect(() => {
let live = true
@@ -59,25 +79,29 @@ export function MessageImage({ attachment, load, labels }: {
}, [attachment, load, attempt])
const label = attachment.name ?? labels.image
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
data-variant={variant}
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
title={labels.open}
aria-label={labels.openNamed(label)}
onClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}>{labels.loading}</span> : <img src={src} alt={label} />}
{src === null
? <span className={css.loading}>{labels.loading}</span>
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
/** Wrapping image group shared by user and assistant history: a lone image
* renders large, several render as 64px square tiles (DeepSeek Chat rule). */
export function ImageGallery({ images, load, align, labels }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
@@ -85,10 +109,11 @@ export function ImageGallery({ images, load, align, labels }: {
labels: MessageImageLabels
}) {
if (images.length === 0) return null
const variant = images.length === 1 ? 'single' : 'tile'
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} variant={variant} labels={labels} />
))}
</div>
)

View File

@@ -1,13 +1,15 @@
/**
* Pure React attachment atoms (zero cordis): the composer draft-image rail,
* the chat-history image gallery, and the original-image lightbox. Owners
* resolve every string through their own locale namespace and pass it down;
* nothing here reads application state.
* 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
*/
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'

View File

@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { DropOverlay } from '../src/DropOverlay.tsx'
afterEach(cleanup)
describe('DropOverlay', () => {
it('portals the invitation with its title and limits desc to the body', () => {
const view = render(
<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加', desc: '最多 20 张,每张 5MB' }} />,
)
const overlay = view.getByRole('status')
expect(overlay.parentElement).toBe(document.body)
expect(overlay.textContent).toContain('图片拖动到此处即可添加')
expect(overlay.textContent).toContain('最多 20 张,每张 5MB')
})
it('omits the desc line when none is resolved', () => {
const view = render(<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加' }} />)
expect(view.getByRole('status').textContent).toBe('图片拖动到此处即可添加')
})
it('drops the desc and switches the illustration while disabled', () => {
const enabled = render(
<DropOverlay disabled={false} labels={{ title: '拖入', desc: '限制' }} />,
)
const enabledSvg = enabled.getByRole('status').querySelector('svg')!.innerHTML
enabled.unmount()
const disabled = render(
<DropOverlay disabled labels={{ title: '当前无法添加图片', desc: '限制' }} />,
)
const overlay = disabled.getByRole('status')
expect(overlay.textContent).toBe('当前无法添加图片')
expect(overlay.querySelector('svg')!.innerHTML).not.toBe(enabledSvg)
})
})

View File

@@ -39,12 +39,13 @@ describe('ImageLightbox', () => {
}
})
it('closes on a backdrop press but not on a press over the image', () => {
it('closes on a mask press but not on a press over the image', () => {
const onClose = vi.fn()
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
fireEvent.mouseDown(view.getByRole('img'))
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
fireEvent.mouseDown(mask)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -29,7 +29,7 @@ const attachment = {
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png点击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
@@ -44,7 +44,7 @@ describe('MessageImage', () => {
it('ignores a click while the thumbnail is still loading', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png点击查看原图' })
expect(view.getByText('图片加载中…')).toBeTruthy()
fireEvent.click(frame)
@@ -54,7 +54,7 @@ describe('MessageImage', () => {
it('falls back to the image label for an unnamed attachment', async () => {
const { name: _named, ...unnamed } = attachment
const load = vi.fn().mockResolvedValue('blob:unnamed')
const view = render(<MessageImage attachment={unnamed} load={load} labels={labels} />)
const view = render(<MessageImage attachment={unnamed} load={load} variant="single" labels={labels} />)
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
})
@@ -64,7 +64,7 @@ describe('MessageImage', () => {
.mockRejectedValueOnce(new Error('offline'))
.mockRejectedValueOnce(new Error('still offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
@@ -73,16 +73,59 @@ describe('MessageImage', () => {
expect(load).toHaveBeenCalledTimes(3)
})
it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
const load = vi.fn().mockResolvedValue('blob:ratio')
const tall = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 2000 }} load={load} variant="single" labels={labels} />,
)
const tallFrame = tall.getByRole('button', { name: 'history.png点击查看原图' })
expect(tallFrame.getAttribute('style')).toContain('width: 60px')
expect(tallFrame.getAttribute('style')).toContain('height: 240px')
await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() })
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
tall.unmount()
const wide = render(
<MessageImage attachment={{ ...attachment, width: 4000, height: 100 }} load={load} variant="single" labels={labels} />,
)
const wideFrame = wide.getByRole('button', { name: 'history.png点击查看原图' })
expect(wideFrame.getAttribute('style')).toContain('width: 240px')
expect(wideFrame.getAttribute('style')).toContain('height: 60px')
await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() })
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
wide.unmount()
const small = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 100 }} load={load} variant="single" labels={labels} />,
)
const smallFrame = small.getByRole('button', { name: 'history.png点击查看原图' })
expect(smallFrame.getAttribute('style')).toContain('width: 100px')
expect(smallFrame.getAttribute('style')).toContain('height: 100px')
})
it('renders a tile at the fixed square without inline sizing', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png点击查看原图' })
expect(frame.getAttribute('data-variant')).toBe('tile')
expect(frame.getAttribute('style')).toBeNull()
})
it('keeps the tile variant on the failed-load retry control', async () => {
const load = vi.fn().mockRejectedValue(new Error('offline'))
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
expect(retry.getAttribute('data-variant')).toBe('tile')
})
it('ignores a load settling after unmount', async () => {
let resolve: ((url: string) => void) | undefined
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
view.unmount()
resolve?.('blob:late')
await Promise.resolve()
let reject: ((error: Error) => void) | undefined
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
const second = render(<MessageImage attachment={attachment} load={failing} labels={labels} />)
const second = render(<MessageImage attachment={attachment} load={failing} variant="single" labels={labels} />)
second.unmount()
reject?.(new Error('late failure'))
await Promise.resolve()
@@ -100,4 +143,15 @@ describe('ImageGallery', () => {
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
})
it('renders a lone image large and several images as square tiles', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
lone.unmount()
const several = render(
<ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
)
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: ed8f888d35693ecaa2667ea432b462f4bb3369cf
README.zh.md: e2a42ee20f6724c2a8f09908aee17abe52e6f585
README.md: e2db456f92151c3602fce0bf40a86e4019cda1cb
README.zh.md: c5f3152006ea37848b21942d886cbfb74af15943

View File

@@ -36,6 +36,8 @@ Keyboard message submission resolves delivery from the addressed session's runni
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.

View File

@@ -36,6 +36,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
逐会话 UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts``attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互machine face 均缺席、`disabled` owner prop整张虚线卡片可经指针打开现有 Workspace picker只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM大语言模型和工具的墙钟时间这些是关于「屏幕上有什么」的窗口作用域事实而非账目压缩compaction使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。

View File

@@ -313,9 +313,9 @@ export function apply(ctx: Context): void {
return null
} catch (error: unknown) {
if (error instanceof UnsupportedImageMediaTypeError) {
return t('image.unsupportedType', {
type: error.mediaType || t('image.unknownType'),
})
// Positive copy: the supported list is fixed in imageMediaType,
// and naming it beats echoing the rejected MIME type back.
return t('image.unsupportedType')
}
return error instanceof Error ? error.message : String(error)
}

View File

@@ -10,6 +10,7 @@
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
import { 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'
@@ -48,34 +49,60 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
const rendered: ReactNode[] = []
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block === undefined) continue
switch (block.kind) {
case 'text':
rendered.push(
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>,
)
break
case 'reasoning':
rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
break
case 'image': {
// Consecutive image blocks share one gallery so several images tile
// into rows instead of each opening a one-image group of its own.
// Keyed by the group's FIRST block index: a streaming append that
// extends the group then only grows `images` instead of remounting
// the gallery under a shifted key.
const start = i
const group = [block]
while (i + 1 < blocks.length) {
const next = blocks[i + 1]
if (next === undefined || next.kind !== 'image') break
group.push(next)
i += 1
}
rendered.push(<ImageGallery key={start} images={group} load={imageLoader} align="start" labels={messageImageLabels(t)} />)
break
}
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call':
break
default:
rendered.push(
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>,
)
}
}
return (
<div className={css.root} data-streaming={streaming || undefined}>
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return (
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)
}
})}
{rendered}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
</div>

View File

@@ -3,11 +3,60 @@
* application state; owners resolve every string). */
import type {
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
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'
/**
* Byte count as user-facing megabytes (`10MB`, `2.5MB`).
* @param bytes - the byte count.
* @returns the rounded megabyte text.
*/
export function imageSizeText(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${Number.isInteger(mb) ? String(mb) : mb.toFixed(1)}MB`
}
/**
* Product copy for a host attachment rejection (the `attachment-error`
* `details.reason`). User-solvable reasons name the limit and the way out;
* reasons the user cannot act on fold into one send-failed line carrying the
* reason code for a bug report.
* @param t - the conversation-namespace translate.
* @param reason - the wire `details.reason` code.
* @param limits - projected limits interpolated into count/size copy, when known.
* @returns the banner text.
*/
export function attachmentErrorText(
t: Translate<ConversationKey>,
reason: string,
limits?: ImageAttachmentLimits,
): string {
switch (reason) {
case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported')
case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
// Undecodable bytes or a declared type its bytes contradict: solvable by
// replacing or re-exporting the file, so it reads as a format problem.
case 'INVALID_IMAGE':
case 'IMAGE_TYPE_MISMATCH':
return t('image.unsupportedType')
case 'TOO_MANY_IMAGES':
if (limits !== undefined) return t('image.tooMany', { count: limits.maxImagesPerMessage })
break
case 'IMAGE_TOO_LARGE':
if (limits !== undefined) return t('image.fileTooLarge', { size: imageSizeText(limits.maxImageBytes) })
break
case 'IMAGES_TOO_LARGE':
if (limits !== undefined) return t('image.totalTooLarge', { size: imageSizeText(limits.maxMessageImageBytes) })
break
default: break
}
return t('image.sendFailed', { reason })
}
/**
* Resolve the original-image lightbox strings.
* @param t - the conversation-namespace translate.
@@ -33,6 +82,25 @@ export function messageImageLabels(t: Translate<ConversationKey>): MessageImageL
}
}
/**
* 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<ConversationKey>,
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.

View File

@@ -25,7 +25,9 @@ export const zh = {
'input.send': '发送消息',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.accessMode': '访问模式,当前:{name}',
'image.dropHint': '松开以添加图片',
'image.dropTitle': '图片拖动到此处即可添加',
'image.dropDesc': '最多 {count} 张,每张 {size}',
'image.dropBlocked': '当前无法添加图片',
'image.pending': '待发送图片',
'image.openOriginal': '查看原图',
'image.openOriginalLabel': '{label},点击查看原图',
@@ -39,8 +41,14 @@ export const zh = {
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.serviceUnavailable': '图片读取服务不可用',
'image.unsupportedType': '支持的图片格式:{type}',
'image.unknownType': '未知格式',
'image.unsupportedType': '支持 PNG、JPG、WebP、GIF 格式的图片',
'image.tooMany': '一条消息最多添加 {count} 张图片',
'image.fileTooLarge': '单张图片不能超过 {size}',
'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片',
'image.tooManyPixels': '图片分辨率过大,请压缩后重试',
'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
'image.subagentUnsupported': '子智能体会话暂不支持图片',
'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'context.system': '系统提示词',
@@ -184,7 +192,9 @@ export const en = {
'input.send': 'Send message',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.accessMode': 'Access mode, current: {name}',
'image.dropHint': 'Drop to add images',
'image.dropTitle': 'Drag images here to add them',
'image.dropDesc': 'Up to {count} images, {size} each',
'image.dropBlocked': 'Images cannot be added right now',
'image.pending': 'Pending images',
'image.openOriginal': 'View original',
'image.openOriginalLabel': '{label}, click to view original',
@@ -198,8 +208,14 @@ export const en = {
'image.preview': 'Original image preview',
'image.closePreview': 'Close original image preview',
'image.serviceUnavailable': 'Image loading service unavailable',
'image.unsupportedType': 'Unsupported image format: {type}',
'image.unknownType': 'unknown format',
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
'image.tooMany': 'A message can include up to {count} images',
'image.fileTooLarge': 'Each image must be smaller than {size}',
'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again',
'image.tooManyPixels': 'Image resolution is too high; compress it and try again',
'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
'image.subagentUnsupported': 'Subagent sessions do not support images yet',
'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'context.system': 'System prompt',

View File

@@ -115,25 +115,6 @@
background: var(--dsw-alias-state-business-primary);
}
.dragActive {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
}
.dropHint {
position: absolute;
z-index: 2;
inset: 4px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
color: var(--dsw-alias-state-business-primary);
font-size: 14px;
font-weight: 600;
pointer-events: none;
}
.accessory {
display: flex;
align-items: center;

View File

@@ -7,23 +7,28 @@
* (running/removed/promptError) are self-selected via useSession. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import {
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
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'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
// The `imageLimits` projection key merge (intake pre-check) arrives with the
// 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 { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
import {
attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels,
} from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
@@ -80,14 +85,22 @@ export function InputBar({
setToast({ seq: toastSeq.current, text })
}, [])
const dismissToast = useCallback(() => { setToast(null) }, [])
// The deployment's image-intake limits (absent while no attachment service
// is composed — the pre-check below then defers entirely to the host).
const imageLimits = useProjection('imageLimits')
// Prompt failures are ordinary failures (no create/attach transaction exists
// anymore): the toast announces promptError, the draft stays in the machine,
// and the user resubmits. A remount over a session whose machine still holds
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface.
// is still pending, and a transient banner is its only surface. Attachment
// rejections show product copy keyed by the wire reason; other codes are
// developer-facing and keep the raw message plus code.
useEffect(() => {
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast])
if (promptError === null) return
showToast(promptError.error.code === 'attachment-error'
? attachmentErrorText(t, promptError.error.details.reason, imageLimits)
: `${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast, t, imageLimits])
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const cardRef = useRef<HTMLDivElement | null>(null)
const dragDepthRef = useRef(0)
@@ -384,10 +397,7 @@ export function InputBar({
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0 && addImages !== undefined) {
const rejected = addImages(files)
if (rejected !== null) showToast(rejected)
}
if (files.length > 0) intakeImages(files)
const text = e.clipboardData.getData('text/plain')
if (text === '') {
if (files.length > 0) e.preventDefault()
@@ -406,38 +416,94 @@ export function InputBar({
keyboard.track(keyboard.snapshot.draft, caret)
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
if (locked || machineBusy || addImages === undefined) return
dragDepthRef.current += 1
setDragActive(true)
}
// Intake pre-check (DeepSeek Chat semantics): an addition that would break
// a projected limit is refused as a whole batch, announced immediately, and
// never enters the rail — no more submit-time failure rolling the rail
// back. The host enforces the same limits at submit for callers that bypass
// this composer.
const intakeImages = useCallback((files: readonly File[]): void => {
if (addImages === undefined || files.length === 0) return
const rejected = ((): string | null => {
if (imageLimits !== undefined) {
// Format precedes limits (DeepSeek Chat's filter order): a batch with
// a non-image must announce the format problem, not a count or size
// it could never pass anyway — addImages rejects it authoritatively.
if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
return addImages(files)
}
if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
}
if (files.some(file => file.size > imageLimits.maxImageBytes)) {
return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
}
const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
+ files.reduce((sum, file) => sum + file.size, 0)
if (total > imageLimits.maxMessageImageBytes) {
return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
}
}
return addImages(files)
})()
if (rejected !== null) showToast(rejected)
}, [addImages, attachments, imageLimits, showToast, t])
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy'
}
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
}
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
dragDepthRef.current = 0
setDragActive(false)
if (locked || machineBusy || addImages === undefined) return
const dropped = [...event.dataTransfer.files]
if (dropped.length > 0) {
const rejected = addImages(dropped)
if (rejected !== null) showToast(rejected)
// 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) }, [])
@@ -573,6 +639,15 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{dragActive && (
<DropOverlay
disabled={!canAcceptDrop}
labels={dropOverlayLabels(t, canAcceptDrop, imageLimits === undefined ? undefined : {
count: imageLimits.maxImagesPerMessage,
size: imageSizeText(imageLimits.maxImageBytes),
})}
/>
)}
{toast !== null && (
<Toast
key={toast.seq}
@@ -594,16 +669,11 @@ export function InputBar({
click's reopen (close-then-open flickers the chip's open echo). */}
<div
ref={cardRef}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{railItems.length > 0 && (

View File

@@ -9,6 +9,7 @@ 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 { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -25,6 +26,40 @@ const attachment = {
name: 'history.png',
}
describe('attachment rejection copy', () => {
const limits = {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
}
it('renders megabytes without a trailing fraction unless one exists', () => {
expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB')
expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB')
})
it('maps user-solvable reasons to limit-naming copy', () => {
expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型')
expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片')
expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试')
expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片')
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片')
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB')
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB请移除部分图片')
expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images')
})
it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => {
expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败INVALID_IMAGE_BASE64请重新添加图片后再试')
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败TOO_MANY_IMAGES请重新添加图片后再试')
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败IMAGE_TOO_LARGE请重新添加图片后再试')
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败IMAGES_TOO_LARGE请重新添加图片后再试')
})
})
describe('assistant images through the label bridge', () => {
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
const view = render(
@@ -60,6 +95,27 @@ describe('assistant images through the label bridge', () => {
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('merges consecutive image blocks into one tiled gallery, split by text', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'image', attachment },
{ kind: 'image', attachment },
{ kind: 'text', text: 'between' },
{ kind: 'image', attachment },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:grouped')}
/>,
)
await view.findAllByAltText('history.png')
const galleries = view.container.querySelectorAll('[data-align="start"]')
expect(galleries).toHaveLength(2)
expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2)
expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown

View File

@@ -56,6 +56,14 @@ interface BenchOptions {
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
/** The `imageLimits` projection value (absent = no attachment service). */
imageLimits?: {
maxImageBytes: number
maxImagesPerMessage: number
maxMessageImageBytes: number
maxImagePixels: number
mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[]
}
draft?: string
running?: boolean
subagent?: Exclude<ConversationSnapshot['subagent'], null>
@@ -146,7 +154,9 @@ function bench(over?: BenchOptions) {
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
(selector ?? (v => v))(key === 'permissions'
? over?.permissions
: key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -212,18 +222,147 @@ describe('image draft rail', () => {
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
})
it('accepts file drops and prevents browser navigation', () => {
it('accepts a drop anywhere on the page under the full-page overlay', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages })
const card = view.container.querySelector('[class*="card"]')!
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
// The drag never touches the composer card: the listeners are page-wide.
expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false)
expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加')
expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false)
expect(dataTransfer.dropEffect).toBe('copy')
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false)
expect(addImages).toHaveBeenCalledWith([image])
expect(view.queryByRole('status')).toBeNull()
})
it('keeps text drags native and hides the overlay when the drag leaves or ends', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages })
// A text drag carries no Files type: no overlay, native behavior stays.
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } })
expect(view.queryByRole('status')).toBeNull()
const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' }
fireEvent.dragEnter(document.body, { dataTransfer })
expect(view.getByRole('status')).toBeTruthy()
fireEvent.dragLeave(document.body, { dataTransfer })
expect(view.queryByRole('status')).toBeNull()
// An aborted drag (Escape) fires dragend without a balancing leave.
fireEvent.dragEnter(document.body, { dataTransfer })
fireEvent.dragEnter(document.querySelector('textarea')!, { dataTransfer })
expect(view.getByRole('status')).toBeTruthy()
fireEvent.dragEnd(window, { dataTransfer })
expect(view.queryByRole('status')).toBeNull()
expect(addImages).not.toHaveBeenCalled()
})
it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
const limits = {
maxImageBytes: 1024 * 1024,
maxImagesPerMessage: 2,
maxMessageImageBytes: 2 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
}
const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
const drop = (files: File[]) => {
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
}
// Count: three at once over a two-image limit → the whole batch refused.
const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits })
drop([png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片')
expect(overCount.props.addImages).not.toHaveBeenCalled()
cleanup()
// Per-file bytes.
const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits })
drop([png(1024 * 1024 + 1, 'big.png')])
expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
expect(overFile.props.addImages).not.toHaveBeenCalled()
cleanup()
// Aggregate bytes across the existing rail plus the new batch.
const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' }
const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
drop([png(1024 * 1024, 'more.png')])
expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
expect(overTotal.props.addImages).not.toHaveBeenCalled()
cleanup()
// Within every limit: the batch passes through to addImages.
const within = bench({ addImages: vi.fn(() => null), imageLimits: limits })
const fits = png(16, 'fits.png')
drop([fits])
expect(within.props.addImages).toHaveBeenCalledWith([fits])
expect(within.view.queryByRole('alert')).toBeNull()
})
it('announces the format problem before any limit when the batch holds a non-image', () => {
const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
const { view } = bench({
addImages,
imageLimits: {
maxImageBytes: 8,
maxImagesPerMessage: 1,
maxMessageImageBytes: 8,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
},
})
// Oversized AND over-count AND wrong type: the format rejection wins.
const files = [
new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }),
new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }),
]
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
expect(addImages).toHaveBeenCalledWith(files)
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
})
it('shows the projected limits in the drop overlay desc line', () => {
const { view } = bench({
addImages: vi.fn(() => null),
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
},
})
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['Files'], files: [], dropEffect: 'none' } })
expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB')
})
it('announces server attachment rejections as product copy, other codes as developer text', () => {
const attachmentError = (reason: string): ConversationSnapshot['promptError'] => ({
op: 'send',
error: { code: 'attachment-error', message: 'raw wire text', details: { reason } },
})
const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') })
expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
cleanup()
const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') })
expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败ATTACHMENT_NOT_REFERENCED')
cleanup()
const other = bench({
promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } },
})
expect(other.view.getByRole('alert').textContent).toContain('boom (internal)')
})
it('shows the blocked overlay and refuses the drop while the composer is locked', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages, inert: true })
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
fireEvent.dragEnter(document.body, { dataTransfer })
expect(view.getByRole('status').textContent).toContain('当前无法添加图片')
fireEvent.dragOver(document.body, { dataTransfer })
expect(dataTransfer.dropEffect).toBe('none')
fireEvent.drop(document.body, { dataTransfer })
expect(addImages).not.toHaveBeenCalled()
expect(view.queryByRole('status')).toBeNull()
})
it('sends an image-only draft and removes its thumbnail', () => {
@@ -250,7 +389,7 @@ describe('image draft rail', () => {
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
vi.useFakeTimers()
try {
const addImages = vi.fn(() => '支持的图片格式text/plain')
const addImages = vi.fn(() => '支持 PNG、JPG、WebP、GIF 格式的图片')
const { view, textarea } = bench({ addImages })
const paste = () => {
fireEvent.paste(textarea, {
@@ -261,12 +400,12 @@ describe('image draft rail', () => {
})
}
paste()
expect(view.getByRole('alert').textContent).toContain('支持的图片格式text/plain')
expect(view.getByRole('alert').textContent).toContain('支持 PNG、JPG、WebP、GIF 格式的图片')
act(() => { vi.advanceTimersByTime(4000) })
expect(view.queryByRole('alert')).toBeNull()
// The identical rejection re-announces: the toast is keyed per show.
paste()
expect(view.getByRole('alert').textContent).toContain('支持的图片格式text/plain')
expect(view.getByRole('alert').textContent).toContain('支持 PNG、JPG、WebP、GIF 格式的图片')
} finally {
vi.useRealTimers()
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-plugins/README.md
README.md: bb487d5e2cbd34406d83867997ede4d70b190d70
README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-plugins
English | [中文](README.zh.md)
Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store.
## Model Experience
None, as this package only visualizes a Host-owned deployment snapshot in browser Settings and registers nothing model-facing.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot.
- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls.

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-plugins
[English](README.md) | 中文
Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote挂载该分区时组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`
页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown而不拥有另一份全局 store。
## 模型体验
无,因为本包只在浏览器设置中展示 Host 拥有的部署快照,不注册任何模型接口。
#### KV Cache 影响
无;本包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。
- **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。

View File

@@ -0,0 +1,80 @@
{
"name": "@deepseek-ai/dsh-client-ui-plugins",
"description": "Read-only Cordis Loader plugin inventory in Web settings",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-plugins"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

View File

@@ -0,0 +1,286 @@
.section {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
max-width: 760px;
color: var(--dsw-alias-label-primary);
}
.heading h2,
.catalogHeading h3,
.status,
.failure p {
margin: 0;
}
.heading h2 {
font-size: 16px;
line-height: 24px;
font-weight: 600;
}
.status,
.failure {
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
.failure {
display: flex;
align-items: center;
gap: 10px;
color: var(--dsw-alias-state-error-primary);
}
.failure button {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
padding: 4px 10px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: inherit;
cursor: pointer;
}
.catalog {
display: flex;
flex-direction: column;
gap: 12px;
}
.search {
position: relative;
display: flex;
align-items: center;
width: 100%;
color: var(--dsw-alias-label-tertiary);
}
.search > svg {
position: absolute;
left: 12px;
pointer-events: none;
}
.search input {
width: 100%;
height: 36px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 0 34px 0 36px;
outline: none;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 13px;
}
.search input::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.search input:focus-visible {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
}
.catalogHeading {
display: flex;
align-items: baseline;
gap: 7px;
padding: 0 2px;
}
.catalogHeading h3 {
font-size: 13px;
line-height: 20px;
font-weight: 600;
}
.catalogHeading span {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
font-variant-numeric: tabular-nums;
}
.cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.card {
min-width: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-layer-3);
}
.card[data-open='true'] {
border-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-shadow-lv1);
}
.cardContent {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 52px;
border: 0;
padding: 12px 14px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.cardContent:hover,
.card[data-open='true'] > .cardContent {
background: var(--dsw-alias-interactive-bg-hover);
}
.cardContent:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: -2px;
}
.cardTitle {
min-width: 0;
overflow: hidden;
font-size: 14px;
line-height: 20px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardTrailing {
display: inline-flex;
flex: none;
align-items: center;
gap: 7px;
color: var(--dsw-alias-label-tertiary);
}
.statusDot {
display: inline-block;
width: 7px;
height: 7px;
flex: none;
border-radius: 999px;
background: var(--dsw-alias-label-tertiary);
}
.statusDot[data-phase='active'] {
background: var(--dsw-alias-state-success-primary);
}
.statusDot[data-phase='failed'] {
background: var(--dsw-alias-state-error-primary);
}
.statusDot[data-phase='loading'] {
background: var(--dsw-alias-state-business-primary);
}
.configTag {
display: inline-flex;
align-items: center;
min-height: 20px;
border-radius: 5px;
padding: 1px 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 16px;
white-space: nowrap;
}
.configTag[data-enabled='true'] {
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
color: var(--dsw-alias-state-success-primary);
}
.chevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.card[data-open='true'] .chevron {
transform: rotate(180deg);
}
.cardDetails {
border-top: 1px solid var(--dsw-alias-border-l2);
padding: 10px 14px 12px;
background: var(--dsw-alias-bg-module-platform);
}
.entryValue {
display: block;
overflow-wrap: anywhere;
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.details {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 6px 10px;
margin: 8px 0 0;
}
.details div {
display: contents;
}
.details dt {
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 17px;
}
.details dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 17px;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
.chevron {
transition: transform 140ms var(--ds-ease-in-out);
}
}
@media (max-width: 680px) {
.cards {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,195 @@
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconChevronDownOutline14,
IconSearchOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PluginsKey } from './locales.ts'
import css from './PluginSettingsSection.module.css'
/** Registration-side Remote face used by the section. */
export interface PluginSettingsSectionInjected {
/** Read a current Host inventory snapshot. */
list: () => Promise<PluginInventorySnapshot>
}
type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
type PluginFiberPhase = PluginInventoryEntry['fiberPhase']
/** Full component props assembled by the Settings slot renderer. */
export type PluginSettingsSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.plugins'>
& InjectFace<PluginSettingsSectionInjected>
type ViewState =
| { readonly status: 'loading' }
| { readonly status: 'error' }
| { readonly status: 'ready'; readonly snapshot: PluginInventorySnapshot }
const PHASE_KEYS = {
pending: 'pending',
loading: 'loadingPhase',
active: 'active',
failed: 'failed',
unloading: 'unloading',
} satisfies Record<Exclude<PluginFiberPhase, null>, PluginsKey>
/** Localized accessible label for one root Fiber phase. */
function phaseLabel(
phase: PluginFiberPhase,
t: PluginSettingsSectionProps['t'],
): string {
return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
}
/** Compact a module specifier without guessing whether its Loader id was generated. */
function moduleShortName(moduleName: string): string {
const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName
return unscoped
.replace(/^cordis:/, '')
.replace(/^cordis-plugin-/, '')
.replace(/^dsh-(?:host-|client-)?/, '')
}
/** Whether an inventory row matches the local catalog query. */
function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean {
if (normalizedQuery.length === 0) return true
return [entry.moduleName, entry.entryId]
.some(value => value.toLocaleLowerCase().includes(normalizedQuery))
}
/** Render the read-only current Loader inventory. */
export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode {
const titleId = useId()
const [request, setRequest] = useState(0)
const [query, setQuery] = useState('')
const [expanded, setExpanded] = useState<PluginInventoryEntry['entryId'] | null>(null)
const [state, setState] = useState<ViewState>({ status: 'loading' })
useEffect(() => {
let current = true
void Promise.resolve().then(() => list()).then(
(snapshot) => { if (current) setState({ status: 'ready', snapshot }) },
() => { if (current) setState({ status: 'error' }) },
)
return () => { current = false }
}, [list, request])
const normalizedQuery = query.trim().toLocaleLowerCase()
const filteredEntries = useMemo(
() => state.status === 'ready'
? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery))
: [],
[normalizedQuery, state],
)
useEffect(() => {
if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) {
setExpanded(null)
}
}, [expanded, filteredEntries])
const retry = (): void => {
setState({ status: 'loading' })
setRequest(value => value + 1)
}
return (
<section className={css.section} aria-labelledby={titleId} aria-busy={state.status === 'loading'}>
<header className={css.heading}>
<h2 id={titleId}>{t('title')}</h2>
</header>
{state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
{state.status === 'error' ? (
<div className={css.failure}>
<p role="alert">{t('error')}</p>
<button type="button" onClick={retry}>{t('retry')}</button>
</div>
) : null}
{state.status === 'ready' ? (
<div className={css.catalog}>
<label className={css.search}>
<IconSearchOutline16 aria-hidden="true" />
<span className={css.visuallyHidden}>{t('search')}</span>
<input
type="search"
value={query}
placeholder={t('search')}
aria-label={t('search')}
onChange={(event) => { setQuery(event.currentTarget.value) }}
/>
</label>
<div className={css.catalogHeading}>
<h3>{t('catalog')}</h3>
<span data-plugin-count={filteredEntries.length}>{filteredEntries.length}</span>
</div>
{state.snapshot.entries.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
{state.snapshot.entries.length > 0 && filteredEntries.length === 0
? <p className={css.status}>{t('emptySearch')}</p>
: null}
{filteredEntries.length > 0 ? (
<ul className={css.cards}>
{filteredEntries.map((entry) => {
const status = phaseLabel(entry.fiberPhase, t)
const title = moduleShortName(entry.moduleName)
const open = expanded === entry.entryId
const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}`
return (
<li
className={css.card}
key={entry.entryId}
data-plugin-entry={entry.entryId}
data-open={open ? 'true' : undefined}
>
<button
className={css.cardContent}
type="button"
aria-expanded={open}
aria-controls={detailId}
aria-label={`${title}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`}
onClick={() => {
setExpanded(current => current === entry.entryId ? null : entry.entryId)
}}
>
<strong className={css.cardTitle} title={entry.moduleName}>{title}</strong>
<span className={css.cardTrailing}>
<span
className={css.statusDot}
data-phase={entry.fiberPhase ?? 'unobserved'}
role="img"
aria-label={status}
title={status}
/>
<span className={css.configTag} data-enabled={entry.enabled ? 'true' : 'false'}>
{t(entry.enabled ? 'enabledTag' : 'disabledTag')}
</span>
<IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
</span>
</button>
{open ? (
<div className={css.cardDetails} id={detailId}>
<code className={css.entryValue} data-loader-entry>{entry.entryId}</code>
<dl className={css.details}>
<div>
<dt>{t('configuration')}</dt>
<dd>{t(entry.enabled ? 'enabledTag' : 'disabledTag')}</dd>
</div>
<div>
<dt>{t('cordis')}</dt>
<dd>{status}</dd>
</div>
</dl>
</div>
) : null}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,47 @@
/** Read-only Host plugin inventory registered into Web Settings. */
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import { PluginSettingsSection, type PluginSettingsSectionInjected } from './PluginSettingsSection.tsx'
import { en, zh, type PluginsKey } from './locales.ts'
export type { PluginSettingsSectionInjected, PluginSettingsSectionProps } from './PluginSettingsSection.tsx'
export type { PluginsKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Read-only Host plugin inventory copy. */
'settings.plugins': PluginsKey
}
}
/** Dictionary namespace owned by this plugin. */
export const NS = 'settings.plugins'
/** Services required by the Settings registration and generated Remote face. */
export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
/** Register the lazy plugin inventory page below Models in Settings. */
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries')
const t = ctx.locale.bind(NS)
const list: PluginSettingsSectionInjected['list'] = async () => {
const result = await ctx.remote.pluginInventory.list()
if (!result.ok) {
throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`)
}
return result.value
}
const injected = (): PluginSettingsSectionInjected => ({ list })
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'plugin-inventory',
order: 15,
label: () => t('nav'),
locale: NS,
inject: injected,
}, PluginSettingsSection))
}

View File

@@ -0,0 +1,50 @@
/** Copy dictionaries for the plugin inventory Settings section. */
/** Simplified Chinese dictionary and key source of truth. */
export const zh = {
nav: '插件',
title: '插件',
loading: '正在读取插件…',
error: '暂时无法读取插件。',
retry: '重试',
search: '搜索插件',
catalog: '插件列表',
empty: '暂无插件。',
emptySearch: '没有匹配的插件。',
enabledTag: '已启用',
disabledTag: '已停用',
configuration: '配置状态',
cordis: 'Cordis 状态',
unobserved: '未挂载',
pending: '等待依赖',
loadingPhase: '加载中',
active: '已挂载',
failed: '挂载失败',
unloading: '卸载中',
} satisfies Record<string, string>
/** Plugin inventory locale key union. */
export type PluginsKey = keyof typeof zh
/** English dictionary checked against the Chinese key set. */
export const en = {
nav: 'Plugins',
title: 'Plugins',
loading: 'Reading plugins…',
error: 'Plugins are temporarily unavailable.',
retry: 'Retry',
search: 'Search plugins',
catalog: 'Plugin list',
empty: 'No plugins are available.',
emptySearch: 'No matching plugins.',
enabledTag: 'Enabled',
disabledTag: 'Disabled',
configuration: 'Configuration',
cordis: 'Cordis status',
unobserved: 'Not mounted',
pending: 'Waiting for dependencies',
loadingPhase: 'Loading',
active: 'Mounted',
failed: 'Mount failed',
unloading: 'Unloading',
} satisfies Record<PluginsKey, string>

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the plugin settings section. */
export function apply(): void {}

View File

@@ -0,0 +1,20 @@
/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-plugins/invariant */
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugins'
/** Cordis companion plugin name. */
export const name = 'client-ui-plugins-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: this package owns a read-only Settings contribution. */
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, NS } from '../src/client/index.ts'
import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx'
import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx'
usePinnedBrowserLanguages('zh-CN')
afterEach(cleanup)
const EMPTY = { entries: [] }
type ListResult =
| { readonly ok: true; readonly value: typeof EMPTY }
| { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
class RemoteService extends Service {
constructor(serviceCtx: Context) {
super(serviceCtx, 'remote')
}
}
new RemoteService(ctx)
const list = vi.fn<() => Promise<ListResult>>()
.mockResolvedValue({ ok: true, value: EMPTY })
ctx.provide('remote.pluginInventory', { list })
return { ctx, slots: ctx.get('slots') as SlotsService, locale, list }
}
function declare(slots: SlotsService): () => void {
return slots.register({
name: 'root',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
} as never, () => null)
}
describe('ui-plugins browser plugin', () => {
it('declares only the services used by the Settings Remote contribution', () => {
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
})
it('registers a localized section without reading the Remote eagerly', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.section')[0]!
expect(entry.component).toBe(PluginSettingsSection)
expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 })
expect(entry.locale).toBe(NS)
expect(resolveSlotLabel(entry.options.label)).toBe('插件')
expect(b.list).not.toHaveBeenCalled()
const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)()
await expect(injected.list()).resolves.toEqual(EMPTY)
expect(b.list).toHaveBeenCalledOnce()
b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })
await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable')
await b.ctx.fiber.dispose()
})
it('follows locale and recovers across late declaration and declarer reload', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries('settings.section')).toHaveLength(0)
const stop = declare(b.slots)
await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) })
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins')
stop()
expect(b.slots.entries('settings.section')).toHaveLength(0)
declare(b.slots)
await vi.waitFor(() => {
expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection)
})
await fiber.dispose()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
await b.ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,128 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx'
import type {
PluginSettingsSectionInjected,
PluginSettingsSectionProps,
} from '../src/client/PluginSettingsSection.tsx'
import { en, type PluginsKey } from '../src/client/locales.ts'
afterEach(cleanup)
type Snapshot = Awaited<ReturnType<PluginSettingsSectionInjected['list']>>
const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t']
const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never
function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps {
return {
close: vi.fn(),
useSessions: unusedHook,
useWorkspaces: unusedHook,
t,
list,
}
}
const SNAPSHOT = {
entries: [
{ entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
{ entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' },
{ entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' },
{ entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' },
{ entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' },
{ entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null },
],
} as unknown as Snapshot
describe('PluginSettingsSection', () => {
it('renders searchable two-column-card semantics with dots and tags', async () => {
const deferred = Promise.withResolvers<Snapshot>()
const list = vi.fn(() => deferred.promise)
const view = render(<PluginSettingsSection {...props(list)} />)
expect(screen.getByText(en.loading)).toBeTruthy()
await act(async () => { deferred.resolve(SNAPSHOT) })
expect(list).toHaveBeenCalledOnce()
expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy()
expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy()
expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6')
expect(screen.getAllByRole('listitem')).toHaveLength(6)
expect(screen.getAllByText(en.enabledTag)).toHaveLength(5)
expect(screen.getByText(en.disabledTag)).toBeTruthy()
for (const value of [
'Mounted',
'Waiting for dependencies',
'Loading',
'Mount failed',
'Unloading',
'Not mounted',
]) {
expect(screen.getByRole('img', { name: value })).toBeTruthy()
}
const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' })
expect(active.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(active)
expect(active.getAttribute('aria-expanded')).toBe('true')
expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d')
expect(screen.getByText(en.configuration)).toBeTruthy()
expect(screen.getByText(en.cordis)).toBeTruthy()
fireEvent.click(active)
expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
fireEvent.click(active)
fireEvent.change(screen.getByRole('searchbox', { name: en.search }), {
target: { value: 'disabled-entry' },
})
expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' }))
expect(screen.getAllByText(en.disabledTag)).toHaveLength(2)
})
it('filters by module name or Loader entry id', async () => {
render(<PluginSettingsSection {...props(async () => SNAPSHOT)} />)
const search = await screen.findByRole('searchbox', { name: en.search })
fireEvent.change(search, { target: { value: 'disabled-entry' } })
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(screen.getByText('directory-picker-native')).toBeTruthy()
fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } })
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(screen.getByText('hmr')).toBeTruthy()
fireEvent.change(search, { target: { value: 'not-a-plugin' } })
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
expect(screen.getByText(en.emptySearch)).toBeTruthy()
})
it('shows a generic failure and retries into the empty state', async () => {
const list = vi.fn<PluginSettingsSectionInjected['list']>()
.mockRejectedValueOnce(new Error('private transport detail'))
.mockResolvedValueOnce({ entries: [] })
render(<PluginSettingsSection {...props(list)} />)
expect((await screen.findByRole('alert')).textContent).toBe(en.error)
expect(screen.queryByText('private transport detail')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.retry }))
await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
expect(await screen.findByText(en.empty)).toBeTruthy()
})
it('contains a synchronous Remote failure and ignores a result after unmount', async () => {
const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list']
const failed = render(<PluginSettingsSection {...props(syncFailure)} />)
expect((await screen.findByRole('alert')).textContent).toBe(en.error)
failed.unmount()
const deferred = Promise.withResolvers<Snapshot>()
const pending = render(<PluginSettingsSection {...props(() => deferred.promise)} />)
pending.unmount()
await act(async () => { deferred.resolve(SNAPSHOT) })
const deferredFailure = Promise.withResolvers<Snapshot>()
const pendingFailure = render(<PluginSettingsSection {...props(() => deferredFailure.promise)} />)
pendingFailure.unmount()
await act(async () => { deferredFailure.reject(new Error('late failure')) })
})
})

View File

@@ -0,0 +1,15 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as PluginsInvariant from '../src/invariant.ts'
describe('ui-plugins invariant companion', () => {
it('registers the empty installer and keeps the node half inert', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined()
const { apply } = await import('../src/index.ts')
apply()
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-settings"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-plugins', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -162,6 +162,7 @@ body {
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12);
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
--dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7);
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60);
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60);
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150);
@@ -253,6 +254,7 @@ body[data-ds-dark-theme] {
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2);
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
--dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7);
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800);
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-850);
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700);

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/examples/acp-demo/README.md
README.md: edc45c9857a631cef72eb41b1a98c390f112291e
README.zh.md: 3798f4bf1e349c27b3fb3a32434e4f25905eddf5
README.md: c1a15a424d9d66b90bbec451e220198bfe0a45df
README.zh.md: 6928590f6483b95312400adc11b9f7b7112ece4a

View File

@@ -34,6 +34,7 @@ The app does not install commands, user interaction, session navigation, configu
| `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. |
| `skills` | owner defaults | Skill registry, local provider, and model-facing skill tool. |
| `toolBash` | owner defaults | Model-facing bash tool config. |
| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | Process-local per-owner active-task admission. |
| `toolTasks` | owner defaults | Generic background-task control config, or `false`. |
| `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. |

View File

@@ -34,6 +34,7 @@ ACPAgent Client Protocol自动化服务器应用默认 agent智能
| `workspaceContext` | 必填 | 工作区指令字节预算/配置,或 `false`。 |
| `skills` | 拥有者默认值 | skill 注册表、本地提供方和面向模型的 skill 工具。 |
| `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置。 |
| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | 进程内按 owner 限制活动任务的准入配置。 |
| `toolTasks` | 拥有者默认值 | 通用后台任务控制配置,或 `false`。 |
| `goals` | 拥有者默认值 | 持久化的同会话目标领域与模型工具,或 `false`。 |

View File

@@ -65,6 +65,8 @@ export interface Config {
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-core. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Process-local background-task admission config forwarded through agent-core. */
tasks?: NonNullable<agentCore.Config['tasks']>
/** Generic background-task controls forwarded through agent-core; set false to omit their tools. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */
@@ -92,6 +94,7 @@ export const Config: z<Config> = z.object({
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
tasks: agentCore.TasksConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
})

View File

@@ -182,6 +182,31 @@ describe('dsh-acp-demo composition', () => {
await ctx.fiber.dispose()
})
it('forwards task admission config to the bundled task provider', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
tasks: { maxConcurrentTasksPerOwner: 1 },
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
let settle!: (outcome: { status: 'killed' }) => void
ctx.tasks.start({
kind: 'bash',
label: 'hold configured slot',
run: () => ({
cancel: () => { settle({ status: 'killed' }) },
done: new Promise((resolve) => { settle = resolve }),
}),
})
expect(() => ctx.tasks.start({
kind: 'bash',
label: 'blocked configured task',
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
})).toThrow('(limit: 1)')
await ctx.fiber.dispose()
})
it('forwards bundled tool config into agent-core', async () => {
const ctx = await mount({
provider: 'mock',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md
README.md: 5957d9a8e9218e18d5d7d0f620b6be811f2c230f
README.zh.md: a47727561808e02f663155bddf1d8cb206bad948
README.md: 789715e53038f610d1e2db79cf56f9aabd681fac
README.zh.md: 7a861297d76d18d5e55539334ca8e7ee5ffef640

View File

@@ -55,11 +55,11 @@ This applies the [Service Definition / Service provider / Consumer separation](.
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? }
// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition.
The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `tasks.maxConcurrentTasksPerOwner` configures the local provider independently of the model-facing `toolTasks` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition.
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.

View File

@@ -55,11 +55,11 @@
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? }
// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent无头和 JSON-RPC 组合会创建 `main`ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。
组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent无头和 JSON-RPC 组合会创建 `main`ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值`tasks.maxConcurrentTasksPerOwner` 配置本地 Service provider并与面向模型的 `toolTasks` 控制工具相互独立`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。
例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。

View File

@@ -22,7 +22,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal'
import * as goalSession from '@deepseek-ai/dsh-goal-session'
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local'
import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants'
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
@@ -75,9 +75,10 @@ export interface GoalConfig {
* `dshHome` to bash environment and local skill discovery, `sessionTitle` to
* the fallback title service, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
* plugins this bundle owns. Provider adapters own their `retryPolicy`; this
* bundle always mounts its executor.
* workspace-context loader, `tasks` to the process-local task provider, and
* `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
* Provider adapters own their `retryPolicy`; this bundle always mounts its
* executor.
* `goals` opts into and configures the persisted goal domain plus its model tool
* and same-session driver; `invariants` configures global and package-filtered
* relational checks. Owner schemas supply defaults for optional input;
@@ -114,6 +115,8 @@ export interface Config {
skills?: SkillConfig
/** Model-facing bash tool config, or false when another plugin owns `bash`. */
toolBash?: toolBash.Config | false
/** Process-local background-task admission config. */
tasks?: TasksConfig
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
/** Global enablement and package-name filters for invariant companions. */
@@ -138,6 +141,9 @@ export const SessionTitleConfigSchema: z<SessionTitleConfig> = SessionTitleServi
export const ToolBashConfigSchema: z<toolBash.Config | false> =
z.union([z.const(false), toolBash.Config])
/** The process-local task registry schema exported for app packages that forward `tasks`. */
export const TasksConfigSchema: z<TasksConfig> = LocalTaskService.Config
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
export const ToolTasksConfigSchema: z<toolTasks.Config> = toolTasks.Config
@@ -158,10 +164,11 @@ export const Config = z.intersect([
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
tasks: TasksConfigSchema,
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
invariants: InvariantService.Config,
goals: z.union([z.const(false), GoalConfigSchema]),
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'sessionTitle' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants' | 'goals'>>,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'sessionTitle' | 'skills' | 'workspaceContext' | 'toolBash' | 'tasks' | 'toolTasks' | 'invariants' | 'goals'>>,
]) as unknown as z<Config>
/**
@@ -181,6 +188,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
workspaceContext: config.workspaceContext,
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.tasks !== undefined ? { tasks: config.tasks } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
...config.invariants !== undefined ? { invariants: config.invariants } : {},
...config.goals !== undefined ? { goals: config.goals } : {},
@@ -228,7 +236,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(toolGoal, config.goals.tool ?? {})
ctx.plugin(goalSession)
}
ctx.plugin(LocalTaskService)
ctx.plugin(LocalTaskService, config.tasks ?? {})
ctx.plugin(InvariantService, config.invariants ?? {})
ctx.plugin(sessionInvariant)
ctx.plugin(agentInvariant)

View File

@@ -300,6 +300,28 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('forwards task admission config to the process-local provider', async () => {
const ctx = await mount({
tasks: { maxConcurrentTasksPerOwner: 1 },
workspaceContext: false,
})
let settle!: (outcome: { status: 'killed' }) => void
ctx.tasks.start({
kind: 'probe',
label: 'hold configured slot',
run: () => ({
cancel: () => { settle({ status: 'killed' }) },
done: new Promise((resolve) => { settle = resolve }),
}),
})
expect(() => ctx.tasks.start({
kind: 'probe',
label: 'blocked configured task',
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
})).toThrow('(limit: 1)')
await ctx.fiber.dispose()
})
it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => {
// ctx.plugin validates + defaults the bundle config first; a direct apply
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
@@ -716,6 +738,7 @@ describe('dsh-agent-spine-demo bundle', () => {
workspaceContext: false as const,
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
tasks: { maxConcurrentTasksPerOwner: 4 },
toolTasks: false as const,
invariants: { enabled: false },
}
@@ -730,6 +753,7 @@ describe('dsh-agent-spine-demo bundle', () => {
workspaceContext: false,
skills: appConfig.skills,
toolBash: appConfig.toolBash,
tasks: appConfig.tasks,
toolTasks: appConfig.toolTasks,
invariants: appConfig.invariants,
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/README.md
README.md: 926cb0b6b87a8ee76cb2dab745a31f620f4e7f5c
README.zh.md: 7ef057ee56e56ddc2baa7092ccbe44fb161b7448
README.md: 1c3b6ab3192fe35a5532183414e45d1b02325e57
README.zh.md: a062d5fce055e3266953993d532a86bec1375377

View File

@@ -13,6 +13,7 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
| [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` |
| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` |
| [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend |
| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` |
`apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam.

View File

@@ -13,6 +13,7 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承
| [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` |
| [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` |
| [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 |
| [`plugin-inventory/`](plugin-inventory/README.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` |
`apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器HTTP 载体。选择器实现可在共享 seam 后互相替换。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 5915d20b176ed6eccdb2c939bdf58b0a122271c5
README.zh.md: 54fcccc3fef46e717aaf05e3a7ace732a0f4b74a
README.md: 059c3eacbcd47bfc39820ab3db5545dbc2e2ccb8
README.zh.md: 4c7e97233a9ebf766ff75daf5cb71ff9d2d22d88

View File

@@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed.
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 09 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.

View File

@@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent然后按追加来源的消息边界分页`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。
会话日志导出是宿主侧的下载面,不是 RPC`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP其中每个文件都是会话存储工件的逐字原文持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 09默认 6使部署可以在 CPU延迟与归档大小之间取舍响应边生成边分块写出宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500持久化后端不提供每会话原始工件时应答 501根会话缺失时应答 404后代缺少存储工件或引用的图片无法读取则整个流失败fail-loud绝不静默少导出。端点由传输层挂载`ApiProxy.downloads.sessionLog` 实现它。

View File

@@ -85,6 +85,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
import type {} from '@deepseek-ai/dsh-user-approval'
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
import { imageLimitsProjectionSchema } from './api/sessions.schema.ts'
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
import { RpcId } from './api/rpc.ts'
@@ -1227,6 +1228,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
})
// The imageLimits projection unit: the attachments config this proxy
// enforces at prompt admission, constant per host boot. `apply` keeps the
// same state reference for every event, so no change frames are ever
// pushed — baselines alone carry the value — and clients pre-check intake
// and label upload affordances from it. Registered here, not in the
// attachment Service Definition: dsh-llm depends on dsh-attachment, so the
// seam package cannot reference the projection registry without a cycle,
// and the per-message rules the value describes are this proxy's own
// admission checks. The child activates only while both seams are composed.
// `view` reading the live service instead of the (null) state is sanctioned
// exactly for boot-constant units: the value cannot change within a process
// lifetime, so the fold stays observationally pure, and a stale persisted
// cache row re-viewing to the current config is the correct outcome.
ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'imageLimits', null>({
key: 'imageLimits',
schema: imageLimitsProjectionSchema,
init: () => null,
apply: state => state,
view: () => projectionCtx.attachments.imageLimits,
stateVersion: 1,
})
})
/** Project both durable inbox lists, optionally including the splice currently being emitted. */
const queueItems = (
agent: Agent,

View File

@@ -15,7 +15,7 @@ import type {
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { WorkspaceId } from './workspace.ts'
import {
SESSION_SEARCH_RESULT_LIMIT,
@@ -213,7 +213,20 @@ export const sessionProjectionsBlockSchema = z.object({
// -1 = empty log (the lastSeq convention of session/subscribed).
asOfSeq: z.number().int().min(-1),
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<SessionProjectionsBlock>
}) as unknown as z.ZodType<Wire<SessionProjectionsBlock>>
/**
* imageLimits projection unit schema (host-side view validation). zod widens
* `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two
* serialize identically, so the cast records exactly that widening.
*/
export const imageLimitsProjectionSchema = z.object({
maxImageBytes: z.number().int().positive(),
maxImagesPerMessage: z.number().int().positive(),
maxMessageImageBytes: z.number().int().positive(),
maxImagePixels: z.number().int().positive(),
mediaTypes: z.array(z.string()),
}) as unknown as z.ZodType<ImageAttachmentLimits>
/** session.history response value (projections rides the tail page only). */
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({

View File

@@ -5,7 +5,7 @@
*/
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
// The pure-type outlet: api/ is browser-importable, and the package root's
@@ -15,6 +15,19 @@ import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/**
* The deployment's image-intake limits: the attachments service's config
* as this proxy enforces it at prompt admission, constant per host boot.
* Clients pre-check count and bytes at intake and show the limits in
* upload affordances. Key absence means no attachment service is
* composed — clients skip the pre-check and let the host answer.
*/
imageLimits: ImageAttachmentLimits
}
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/**

View File

@@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -86,6 +87,51 @@ describe('session.history projections block', () => {
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
})
it('publishes the attachments imageLimits as a constant unit while both seams are composed', async () => {
const { ctx, session } = await harness(true)
const limits = {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
}
await ctx.plugin(class extends AttachmentStore {
readonly imageLimits = limits
validateImage(): Promise<void> { return Promise.resolve() }
saveImage(): Promise<never> { return Promise.reject(new Error('unused')) }
readImage(): Promise<never> { return Promise.reject(new Error('unused')) }
})
const gateway = api(ctx)
seedMessages(session, 2)
const response = await gateway.sessions.history(request({ sessionId: session.id }))
if (!response.result.ok) throw new Error('history failed')
expect(response.result.value.projections?.values['imageLimits']).toEqual(limits)
// Constant unit: appending events must never broadcast an imageLimits frame.
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const stream = gateway.events.mux({ rpcId: RpcId('t-limits-mux'), payload: {} }, abort.signal)
const frames: MuxFrame[] = []
const drained = (async () => {
for await (const envelope of stream) {
frames.push(envelope.payload)
if (frames.some(f => f.type === 'session/event')) abort.abort()
}
})().catch(() => {})
seedMessages(session, 1)
await drained
expect(frames.some(f => f.type === 'session/projection' && f.key === 'imageLimits')).toBe(false)
})
it('leaves the imageLimits key absent while no attachment service is composed', async () => {
const { ctx, session } = await harness(true)
seedMessages(session, 1)
const response = await api(ctx).sessions.history(request({ sessionId: session.id }))
if (!response.result.ok) throw new Error('history failed')
expect(response.result.value.projections).toBeDefined()
expect('imageLimits' in (response.result.value.projections?.values ?? {})).toBe(false)
})
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md
README.md: 23fbf07d7900ecc881f81b5da3f8cbe6a45669de
README.zh.md: 87058cde595b83e980b8f3cec4192e6099b8d9ea

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-host-plugin-inventory
English | [中文](README.zh.md)
Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase.
The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation.
## Model Experience
None, as this Host-only inventory projection registers no prompt, tool, message, or provider request.
#### KV Cache effect
None; this package never assembles model input.
## Known Limitations and Deferred Work
- **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists.
- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins.

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-host-plugin-inventory
[English](README.md) | 中文
当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。
阶段为 `pending``loading``active``failed``unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`TypeRT 生成由 `./typert``./remote` 导出的 Host 和 Client Remote 产物。
该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。
## 模型体验
无,因为这个仅限 Host 的清单投影不注册提示词、工具、消息或提供方请求。
#### KV Cache 影响
无;本包从不组装模型输入。
## 已知限制与暂缓事项
- **仅表示调用当下** —— 结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber就会报告 `null`,而不区分其原因。
- **无来源与修改能力** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。

View File

@@ -0,0 +1,68 @@
{
"name": "@deepseek-ai/dsh-host-plugin-inventory",
"description": "Read-only Remote projection of current Cordis Loader plugin state",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/plugin-inventory"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,72 @@
/** Read-only projection of the current Cordis Loader plugin entries. */
import type { Context, FiberState } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
// TypeRT-generated ./typert and ./remote artifacts import Zod at runtime.
import type {} from 'zod'
import type {
PluginEntryId,
PluginFiberPhase,
PluginInventoryEntry,
PluginInventorySnapshot,
} from './types.ts'
export type * from './types.ts'
/** Brand an existing Loader-tree entry id at the owning boundary. */
function pluginEntryId(value: string): PluginEntryId {
return value as PluginEntryId
}
/** Runtime mirror: FiberState is a cross-package const enum. */
const FIBER_STATE = {
PENDING: 0 as FiberState.PENDING,
LOADING: 1 as FiberState.LOADING,
ACTIVE: 2 as FiberState.ACTIVE,
FAILED: 3 as FiberState.FAILED,
DISPOSED: 4 as FiberState.DISPOSED,
UNLOADING: 5 as FiberState.UNLOADING,
} as const
/** Complete public projection of Cordis Fiber states. */
const FIBER_PHASE = {
[FIBER_STATE.PENDING]: 'pending',
[FIBER_STATE.LOADING]: 'loading',
[FIBER_STATE.ACTIVE]: 'active',
[FIBER_STATE.FAILED]: 'failed',
[FIBER_STATE.DISPOSED]: null,
[FIBER_STATE.UNLOADING]: 'unloading',
} as const satisfies Record<FiberState, PluginFiberPhase>
/** Remote-only service exposing the Loader's current non-group entry state. */
export class PluginInventoryService extends GatewayService {
static inject = ['loader']
constructor(ctx: Context) {
super(ctx, 'pluginInventory')
}
/**
* Read the Loader directly on every call. Cordis's internal plugin/status
* events already maintain Entry.fiber and Fiber.state, so a second cache
* would only add another lifecycle truth to keep synchronized.
* @returns Current non-group Loader entries in Loader order.
*/
@Remote('list')
list(): PluginInventorySnapshot {
const entries: PluginInventoryEntry[] = []
for (const entry of this.ctx.loader.entries()) {
if (entry.options.group) continue
entries.push({
entryId: pluginEntryId(entry.id),
moduleName: entry.options.name,
enabled: !entry.disabled,
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
})
}
return { entries }
}
}
export default PluginInventoryService

View File

@@ -0,0 +1,20 @@
/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-plugin-inventory/invariant */
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-plugin-inventory'
/** Cordis companion plugin name. */
export const name = 'host-plugin-inventory-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: every snapshot is projected directly from Loader-owned state. */
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,28 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Stable Loader-tree identity of one configured plugin entry. */
export type PluginEntryId = Branded<'PluginEntryId'>
/** Lifecycle state of an entry's root Fiber, or null when it has no live root Fiber. */
export type PluginFiberPhase =
| 'pending'
| 'loading'
| 'active'
| 'failed'
| 'unloading'
| null
/** One non-group Loader entry exposed to trusted clients. */
export interface PluginInventoryEntry {
readonly entryId: PluginEntryId
/** Exact module specifier imported by the Loader entry. */
readonly moduleName: string
/** Effective Loader enablement, including disabled ancestor groups. */
readonly enabled: boolean
readonly fiberPhase: PluginFiberPhase
}
/** Point-in-time inventory returned by the plugin inventory Remote. */
export interface PluginInventorySnapshot {
readonly entries: readonly PluginInventoryEntry[]
}

View File

@@ -0,0 +1,16 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as PluginInventoryInvariant from '../src/invariant.ts'
describe('plugin-inventory invariant companion', () => {
it('registers the package-owned empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = ctx.plugin(PluginInventoryInvariant)
await expect(fiber.await()).resolves.toBeDefined()
await fiber.dispose()
await expect(ctx.plugin(PluginInventoryInvariant).await()).resolves.toBeDefined()
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context, type Plugin } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { remoteMethods } from '@deepseek-ai/dsh-type-meta'
import PluginInventoryService from '../src/index.ts'
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
const activePlugin: Plugin.Function = () => {}
const pendingPlugin: Plugin.Object = {
inject: ['neverReady'],
apply() {},
}
async function harness(): Promise<{
ctx: Context
inventory: PluginInventoryService
}> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(Loader)
ctx.loader.builtins.active = activePlugin
ctx.loader.builtins.pending = pendingPlugin
await ctx.plugin(PluginInventoryService)
const inventory = ctx.get('pluginInventory') as PluginInventoryService
return { ctx, inventory }
}
describe('PluginInventoryService', () => {
it('publishes one direct list method under the pluginInventory namespace', async () => {
const { inventory } = await harness()
expect(inventory.typertGateway).toMatchObject({
serviceKey: 'pluginInventory',
namespace: 'pluginInventory',
})
expect(remoteMethods(inventory)).toEqual([
{ method: 'list', invocation: { kind: 'direct' } },
])
})
it('projects current non-group Loader entries without a second cache', async () => {
const { ctx, inventory } = await harness()
const activeId = await ctx.loader.create({ name: 'cordis:active' })
const pendingId = await ctx.loader.create({ name: 'cordis:pending' })
const disabledId = await ctx.loader.create({
name: 'cordis:not-installed',
disabled: true,
})
await ctx.loader.create({ name: 'cordis:active', group: true })
expect(inventory.list()).toEqual({
entries: [
{
entryId: activeId,
moduleName: 'cordis:active',
enabled: true,
fiberPhase: 'active',
},
{
entryId: pendingId,
moduleName: 'cordis:pending',
enabled: true,
fiberPhase: 'pending',
},
{
entryId: disabledId,
moduleName: 'cordis:not-installed',
enabled: false,
fiberPhase: null,
},
],
})
await ctx.loader.update(activeId, { disabled: true })
expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({
entryId: activeId,
moduleName: 'cordis:active',
enabled: false,
fiberPhase: null,
})
await ctx.loader.remove(pendingId)
expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false)
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../util/brand"
},
{
"path": "../../typert/type-meta"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1150,7 +1150,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'abstract start(spec: TaskStart): TaskId',
jsDoc: '/**\n * Preflight access, validation, and owner cleanup before starting and\n * atomically registering work. A throwing starter leaves nothing registered;\n * after it returns, registration cannot fail. Settlement records the outcome,\n * notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `<kind>-N` id.\n */',
jsDoc: '/**\n * Preflight access, validation, owner cleanup, and implementation-owned\n * admission before starting and atomically registering work. Any preflight\n * rejection leaves no task id or execution resource. A throwing starter\n * leaves nothing registered; after it returns, registration cannot fail.\n * Settlement records the outcome, notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `<kind>-N` id.\n */',
},
{
signature: 'abstract list(caller?: Agent): TaskSnapshot[]',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
README.md: 2817e02861db6caad89cad258d14a90c34afcbaf
README.zh.md: 47f06ec2902bf823ea47751a806b5e2cb9789752
README.md: bf0af8779f0cc3e2c20382db40be4715814e78f4
README.zh.md: 6d0e102d9b4c55049106f937b6f04ec26a458cb6

View File

@@ -12,7 +12,8 @@ Local Service provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
- **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
- **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes.
- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
## Model Experience
@@ -27,6 +28,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
- **Windows tree support is best-effort** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary.
- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots.
- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor.
- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner.
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.

View File

@@ -12,7 +12,8 @@
- **基于偏移量的读取**收集模式的读取器按完整流的字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **可执行文件查找**`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
- **先终止再等待退出的 dispose资源释放**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
- **先终止再等待退出的 dispose资源释放**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。
- **同步宿主退出最终清理**:服务 effect 仍有效时Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer不改变宿主退出码与诊断会分别包含每个目标的失败也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。
## 模型体验
@@ -27,6 +28,7 @@
- **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
- **终端进程检查仅支持 LinuxmacOS**检查器没有受支持的平台实现时终端原语会失败Linux 精确探针覆盖 x64 与 arm64macOS 则使用 `ps` 快照。
- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。
- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM``SIGINT``SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。

View File

@@ -46,6 +46,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"

View File

@@ -1,7 +1,8 @@
/**
* Local Service provider for the subprocess capability seam. Each spawn is a detached
* process tree with the spec's per-stream stdio dispositions; disposal
* terminates and joins live trees. It has no config: every disposition and
* process tree with the spec's per-stream stdio dispositions. Normal disposal
* terminates and joins live trees; Node's synchronous exit phase force-stops
* any trees the service still owns. It has no config: every disposition and
* limit arrives on the spec, so the deployment-varying choices stay with the
* caller's config (the bash executor's, the LSP host's, …).
* @module @deepseek-ai/dsh-subprocess-local
@@ -21,7 +22,7 @@ import type {
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import { childEnv, spawnSubprocess } from './spawn.ts'
import type { SpawnInternals } from './spawn.ts'
import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
import { createProcessInspector } from './process-inspector.ts'
import type { ProcessInspector } from './process-inspector.ts'
import { LocalTerminalHandle } from './terminal.ts'
@@ -30,13 +31,14 @@ import { LocalTerminalHandle } from './terminal.ts'
* Local subprocess service: detached process trees, Node-shaped stdio
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
* files), credential-scrubbed environment, and tree-scoped signalling with
* SIGTERM→grace→SIGKILL escalation.
* SIGTERM→grace→SIGKILL escalation, plus synchronous final termination during
* JavaScript-observable host exit.
*/
export class LocalSubprocessService extends SubprocessService {
/** Live handles retained only so disposal can terminate and join them. */
private live = new Set<SubprocessHandle>()
/** Live terminal sessions retained through whole-session quiescence. */
private terminals = new Set<SubprocessTerminalHandle>()
/** Live handles retained for normal disposal and synchronous host-exit finalization. */
private live = new Set<LocalSubprocessHandle>()
/** Live terminals retained through normal quiescence or host-exit finalization. */
private terminals = new Set<LocalTerminalHandle>()
/** Test hook: spill and platform knobs forwarded to spawnSubprocess. */
internals: SpawnInternals = {}
/** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
@@ -44,30 +46,61 @@ export class LocalSubprocessService extends SubprocessService {
constructor(ctx: Context) {
super(ctx)
ctx.effect(() => async () => {
// Terminate (escalating), then await WHOLE-TREE exit — not just the
// direct child's settlement — so even a TERM-trapping descendant cannot
// outlive the fiber.
const pending: Promise<unknown>[] = []
for (const handle of this.live) {
handle.terminate()
// Spawn-failure rejections already settled and left the live set.
pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
ctx.effect(() => {
const onHostExit = (): void => { this.terminateForHostExit() }
process.prependListener('exit', onHostExit)
return async () => {
try {
await this.disposeManagedProcesses()
} finally {
process.off('exit', onHostExit)
}
}
for (const terminal of this.terminals) {
pending.push(terminal.terminate())
}
this.live.clear()
this.terminals.clear()
const outcomes = await Promise.allSettled(pending)
const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected'
? [outcome.reason as unknown]
: [])
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed')
}, 'local subprocess teardown')
}
private terminateForHostExit(): void {
for (const handle of this.live) {
try {
handle.terminateForHostExit()
} catch (_ordinaryTreeTerminationFailed) {
// Host exit cannot await or report one target; continue with the rest.
}
}
for (const terminal of this.terminals) {
try {
terminal.terminateForHostExit()
} catch (_terminalTerminationFailed) {
// One terminal must not prevent final termination of another target.
}
}
}
private async disposeManagedProcesses(): Promise<void> {
// Terminate (escalating), then await WHOLE-TREE exit — not just the
// direct child's settlement — so even a TERM-trapping descendant cannot
// outlive the fiber. Keep both sets authoritative while these waits are
// pending so a shorter process-level exit bound can still force-kill them.
const pending: Promise<unknown>[] = []
for (const handle of this.live) {
handle.terminate()
// Spawn-failure rejections already settled and left the live set.
pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
}
for (const terminal of this.terminals) {
pending.push(terminal.terminate())
}
const outcomes = await Promise.allSettled(pending)
const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected'
? [outcome.reason as unknown]
: [])
if (failures.length > 0) this.terminateForHostExit()
this.live.clear()
this.terminals.clear()
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed')
}
async resolveExecutable(
command: string,
env?: Readonly<Record<string, string>>,

View File

@@ -58,6 +58,16 @@ export interface SpawnInternals {
linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined
}
/**
* Local-only synchronous final termination used by the owning service during
* host exit and as the last fallback after failed normal disposal. It is
* intentionally absent from the public subprocess seam.
*/
export interface LocalSubprocessHandle extends SubprocessHandle {
/** Force-terminate the current tree synchronously without starting timers or waits. */
terminateForHostExit(): void
}
/**
* Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
* awaited teardown must keep the event loop alive until the tree really
@@ -313,7 +323,7 @@ function signalTree(
* @returns live subprocess handle.
* @throws when `graceMs` cannot be represented by one Node timer.
*/
export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle {
if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
@@ -442,6 +452,10 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs)
}
const terminateForHostExit = (): void => {
kill('SIGKILL')
}
// The caller owns timeout classification; this layer only reacts to abort.
const onAbort = (): void => { terminate() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
@@ -523,6 +537,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
},
done,
terminate,
terminateForHostExit,
waitForExit,
}
}

View File

@@ -110,6 +110,33 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
return cleanup
}
/**
* Force-terminate the observable session synchronously during Node's exit
* event. This does not claim quiescence and does not replace terminate().
*/
terminateForHostExit(): void {
this.forceStopDescendants()
this.forceStopShell()
this.forceStopDescendants()
}
private forceStopShell(): void {
if (this.exited) return
if (this.rootIdentity !== undefined) {
try {
this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
} catch (_rootExitedDuringHostExit) {
// Exact identity signalling contains both exit races and PID reuse.
}
return
}
try {
this.terminal.kill('SIGKILL')
} catch (_unidentifiedShellExitedDuringHostExit) {
// Without a captured identity, node-pty is the only root kill primitive.
}
}
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
return members.filter(member => this.inspector.isAlive(member))
}
@@ -152,6 +179,16 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
}
}
private forceStopDescendants(): void {
let members = this.trackedDescendants
try {
members = this.descendants()
} catch (_processTableUnavailableDuringHostExit) {
// Preserve already-captured identities when a final process-table scan fails.
}
this.signalMembers(members, 'SIGKILL')
}
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
const members: ProcessIdentity[] = []
const seen = new Set<string>()

View File

@@ -0,0 +1,16 @@
import { spawn } from 'node:child_process'
import { writeFile } from 'node:fs/promises'
const [statePath] = process.argv.slice(2)
if (statePath === undefined) throw new Error('usage: managed-tree.ts <state-path>')
process.on('SIGTERM', () => {})
process.on('SIGHUP', () => {})
const descendant = spawn(process.execPath, [
'-e',
'process.on("SIGTERM",()=>{});process.on("SIGHUP",()=>{});setInterval(()=>{},60_000)',
], { stdio: 'ignore' })
if (descendant.pid === undefined) throw new Error('managed descendant did not publish a pid')
await writeFile(statePath, JSON.stringify({ root: process.pid, descendant: descendant.pid }))
setInterval(() => {}, 60_000)

Some files were not shown because too many files have changed in this diff Show More