fix(web): harden multimodal draft and storage lifecycle

This commit is contained in:
creatixchu
2026-07-31 17:15:08 +08:00
parent f415e16c83
commit 8b00482d7c
34 changed files with 303 additions and 129 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/attachment/attachment-local/README.md
README.md: 310120bd1c3573da1e7c60334d5c2712195f3186
README.zh.md: 9fd3a857eca1c25c90b665735f67d2c27c92334a
README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f
README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIXWindows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIXWindows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。

View File

@@ -2,8 +2,8 @@
import { createHash, randomUUID } from 'node:crypto'
import { constants } from 'node:fs'
import { chmod, link, mkdir, open, readFile, stat, unlink } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises'
import { dirname, join, parse, resolve } from 'node:path'
import {
AttachmentError,
AttachmentId,
@@ -17,6 +17,7 @@ import type {
import { detectImage, probeImage } from './image.ts'
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
const durableHomes = new Set<string>()
function digest(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
@@ -83,31 +84,6 @@ async function syncDirectory(path: string): Promise<void> {
}
}
/**
* Walk up from a preferred boundary to the deepest ancestor that already
* exists. A first save may create DSH_HOME itself (recursive mkdir), and a
* directory this process creates is not durable until its parent entry syncs
* — so only a pre-existing directory may be vouched as the durable stop.
* @param path - preferred absolute boundary.
* @returns `path` when it exists, else its closest existing ancestor.
*/
async function existingBoundary(path: string): Promise<string> {
let level = resolve(path)
while (true) {
try {
await stat(level)
return level
} catch {
// Swallows only the stat probe's failure: a missing (or unreadable)
// level simply moves the boundary up; mkdir later surfaces real errors.
}
const parent = dirname(level)
/* v8 ignore next -- filesystem-root guard: the root directory always exists, so stat returns first. */
if (parent === level) return level
level = parent
}
}
/**
* Create one private directory tree and persist every ancestor entry up to a
* caller-vouched durable boundary. The walk deliberately ignores what mkdir
@@ -134,6 +110,20 @@ async function ensureDurableDirectory(path: string, boundary: string): Promise<v
}
}
/**
* Establish this process's proof that one DSH_HOME entry and every ancestor
* below the filesystem root are durable. Mere existence is insufficient: a
* concurrent process may have created the directory but not synced its parent.
*/
async function ensureDurableHome(path: string): Promise<string> {
const home = resolve(path)
if (!durableHomes.has(home)) {
await ensureDurableDirectory(home, parse(home).root)
durableHomes.add(home)
}
return home
}
/**
* Save and verify immutable image bytes below a versioned attachment root.
* @param root - absolute `DSH_HOME/attachments/v1` root.
@@ -147,12 +137,10 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
const sha256 = digest(input.data)
const bucket = join(root, 'objects', sha256.slice(0, 2))
const staging = join(root, 'tmp')
// The preferred boundary is the root's grandparent (DSH_HOME for the
// documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be
// first-created by a concurrent save, so their entries sync on every path.
// When DSH_HOME itself does not exist yet, the boundary retreats to its
// closest existing ancestor so the first save syncs the new home entry too.
const boundary = await existingBoundary(dirname(dirname(resolve(root))))
// Establish DSH_HOME itself against the filesystem root once per process.
// Every process performs that proof independently, so observing a directory
// another process created can never be mistaken for durable publication.
const boundary = await ensureDurableHome(dirname(dirname(resolve(root))))
await ensureDurableDirectory(bucket, boundary)
await ensureDurableDirectory(staging, boundary)
const temporary = join(staging, randomUUID())

View File

@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
import { constants } from 'node:fs'
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join, parse, resolve } from 'node:path'
import { mkdtemp, rm } from 'node:fs/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
import sharp from 'sharp'
@@ -43,6 +43,17 @@ async function root(): Promise<string> {
return join(value, 'attachments', 'v1')
}
function parentChainToRoot(path: string): string[] {
const parents: string[] = []
let level = resolve(path)
const root = parse(level).root
while (level !== root) {
level = dirname(level)
parents.push(level)
}
return parents
}
afterEach(async () => {
await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
@@ -58,10 +69,11 @@ describe('local attachment store', () => {
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
// Every level between each created directory and the vouched boundary
// syncs unconditionally — "already existed" is not "already durable"
// when a concurrent first save may have created but not yet synced it.
// Each process first proves DSH_HOME durable all the way to the filesystem
// root; existence alone cannot vouch for a concurrent creator's fsync.
// Later directory creation can then stop at that process-proven boundary.
expect(fsControl.syncedDirectories).toEqual([
...parentChainToRoot(base),
// bucket chain: every parent entry between the bucket and the boundary.
objects,
storageRoot,
@@ -77,7 +89,7 @@ describe('local attachment store', () => {
])
})
it('retreats the durable boundary to the closest existing ancestor when the home directory does not exist yet', async () => {
it('creates and persists a missing nested home directory against the filesystem root', async () => {
const storageRoot = join(await root(), 'home', 'attachments', 'v1')
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)

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: 8b4a8fddc2fdc48873a4f93c99fce62a3dd17766
README.zh.md: cac04f5735f30498a301fb1a70748f8d7426dfbf
README.md: 8e8cb23cf56745116142d786ad712d7230f4f2c5
README.zh.md: dc2416d8b4dafa06f715e60f97e6dcd6d7fc29d9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. Its independent `maxRequestBodyBytes` config caps every buffered API request (32 MiB default) and must be large enough for the configured aggregate image payload after base64 and envelope expansion; changing image policy does not silently redefine the carrier cap for text and other methods. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。其独立的 `maxRequestBodyBytes` 配置会限制每个缓冲 API 请求(默认 32 MiB并且必须足以容纳配置的图片总载荷经 base64 和请求封装膨胀后的大小;修改图片策略不会静默地为文本和其他方法重定义载体上限。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## /api 浏览器信任栅栏

View File

@@ -16,6 +16,8 @@ export const name = 'client-connection'
/** Headroom for RPC JSON fields around the aggregate base64 image payload. */
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
/** Independent default carrier cap; deployments may raise it for larger valid non-image RPCs. */
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
/** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy', 'attachments']
@@ -31,10 +33,17 @@ export interface ConnectionConfig {
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
/**
* Maximum buffered JSON body for every `/api` request. This carrier policy
* is independent of image limits but must be large enough for the configured
* aggregate image bytes after base64 and envelope expansion.
*/
maxRequestBodyBytes?: number
}
export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
})
/**
@@ -80,9 +89,16 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy)
const maxRequestBodyBytes = Math.ceil(
const requiredImageBodyBytes = Math.ceil(
ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
if (maxRequestBodyBytes < requiredImageBodyBytes) {
throw new Error(
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
)
}
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,

View File

@@ -51,7 +51,10 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
return { response, state }
}
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
async function mounted(config?: {
trustedHosts?: string[]
maxRequestBodyBytes?: number
}): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
@@ -96,6 +99,17 @@ describe('connection node half', () => {
expect(routes).toHaveLength(0)
})
it('fails loud when the independent carrier cap cannot hold the configured image batch', () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
ctx.provide('attachments', fakeAttachments())
expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) })
.toThrow(/must be at least .* aggregate image limit/)
expect(routes).toHaveLength(0)
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
const { routes, dispose } = await mounted()
const { response, state } = fakeResponse()

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: b87d562d9faed9beeb7881f1cf037ce75a046dde
README.zh.md: c2dbec5987fef987d5d89627259a1769ee0a29f3
README.md: 119984642fb668b6b07dd1eddc5a58e0681906c9
README.zh.md: 3aed3fdfa0cf5a46780289df96bd975b357835a0

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store; a mixed text-and-image draft moves only when the destination accepts the complete image batch, otherwise both parts stay with the source. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.

View File

@@ -4,7 +4,7 @@
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock与输入区一同 sticky 的会话统计行)、输入区 dock带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store;只有目标接受完整图片批次,图文混合草稿才会移动,否则文本和图片都留在来源端。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。

View File

@@ -13,7 +13,7 @@ import type {
import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ConversationService, UnsupportedImageMediaTypeError } from './service.ts'
import type { IConversation } from './service.ts'
import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
@@ -158,15 +158,17 @@ export function apply(ctx: Context): void {
const draft = from.snapshot.draft
const imageIds = from.snapshot.imageIds
const next = inputHub.shell(nextId)
if (draft !== '') {
next.setDraft(draft)
from.setDraft('')
}
// Transfer only on acceptance: a destination shell mid-submission
// refuses, and the drafts must stay owned (and releasable) by the
// source shell instead of silently leaking their object URLs.
if (imageIds.length > 0 && next.addImages(imageIds)) {
for (const id of imageIds) from.removeImage(id)
// refuses the whole mixed draft, which remains owned (and releasable)
// by the source shell instead of splitting text from its images.
if (imageIds.length === 0 || next.addImages(imageIds)) {
if (draft !== '') {
next.setDraft(draft)
from.setDraft('')
}
if (imageIds.length > 0) {
for (const id of imageIds) from.removeImage(id)
}
}
}
sessions.open(nextId)
@@ -242,6 +244,11 @@ 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'),
})
}
return error instanceof Error ? error.message : String(error)
}
},

View File

@@ -71,8 +71,9 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, loadImage = unavailableImage, time, seq, onFork, t,
blocks, streaming, interrupted, loadImage, time, seq, onFork, t,
}: AssistantMarkdownProps) {
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
@@ -95,7 +96,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
)
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={loadImage} align="start" />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (
@@ -123,7 +124,3 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
</div>
)
})
function unavailableImage(): Promise<string> {
return Promise.reject(new Error('图片读取服务不可用'))
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
import css from './MessageImage.module.css'
@@ -7,9 +8,10 @@ import css from './MessageImage.module.css'
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Compact history renderer with retryable loading and double-click original preview. */
export function MessageImage({ attachment, load }: {
export function MessageImage({ attachment, load, t }: {
attachment: ImageAttachmentRef
load: ImageLoader
t: ChatViewSlotProps['t']
}) {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
@@ -33,36 +35,37 @@ export function MessageImage({ attachment, load }: {
return () => { live = false }
}, [attachment, load])
const label = attachment.name ?? '图片'
if (error) return <button type="button" className={css.error} onClick={request}></button>
const label = attachment.name ?? t('image.label')
if (error) return <button type="button" className={css.error} onClick={request}>{t('image.loadFailed')}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
title="双击查看原图"
aria-label={`${label},双击查看原图`}
title={t('image.openOriginal')}
aria-label={t('image.openOriginalLabel', { label })}
onDoubleClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}></span> : <img src={src} alt={label} />}
{src === null ? <span className={css.loading}>{t('image.loading')}</span> : <img src={src} alt={label} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} />}
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} t={t} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
export function ImageGallery({ images, load, align }: {
export function ImageGallery({ images, load, align, t }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
align: 'start' | 'end'
t: ChatViewSlotProps['t']
}) {
if (images.length === 0) return null
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} />
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} t={t} />
))}
</div>
)

View File

@@ -152,8 +152,9 @@ function projectUserText(text: string): ReactNode {
}
export const MessageItem = memo(function MessageItem({
node, loadImage = unavailableImage, retryActive = false, onFork, t,
node, loadImage, retryActive = false, onFork, t,
}: MessageItemProps) {
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
@@ -161,7 +162,7 @@ export const MessageItem = memo(function MessageItem({
return (
<div className={css.userRow}>
<div className={css.userStack}>
<ImageGallery images={images} load={loadImage} align="end" />
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
{(text !== '' || rest.length > 0) && <div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => (
@@ -186,7 +187,7 @@ export const MessageItem = memo(function MessageItem({
return (
<div className={css.userRow}>
<div className={css.userStack}>
<ImageGallery images={images} load={loadImage} align="end" />
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
<div className={css.bubble}>
<span className={css.badge}>{t('message.steering')}</span>
{projectUserText(text)}
@@ -212,7 +213,3 @@ export const MessageItem = memo(function MessageItem({
)
}
})
function unavailableImage(): Promise<string> {
return Promise.reject(new Error('图片读取服务不可用'))
}

View File

@@ -162,12 +162,7 @@ export class InputHub implements InputService {
// see them — release the drafts here instead of resurrecting them onto
// a dead instance where they would leak for the page lifetime.
if (this.shells.get(session.sessionId) === shell) {
if (shell?.snapshot.imageIds.length === 0) {
shell.restoreImages(imageIds)
} else {
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
for (const id of imageIds) conversation?.releaseDraftImage(id)
}
shell?.restoreImages(imageIds)
if (shell?.snapshot.draft === '') shell.setDraft(text)
return
}

View File

@@ -23,6 +23,20 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'image.dropHint': '松开以添加图片',
'image.pending': '待发送图片',
'image.openOriginal': '双击查看原图',
'image.openOriginalLabel': '{label},双击查看原图',
'image.remove': '移除图片 {name}',
'image.original': '原图',
'image.label': '图片',
'image.loadFailed': '图片加载失败,点击重试',
'image.loading': '图片加载中…',
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.serviceUnavailable': '图片读取服务不可用',
'image.unsupportedType': '不支持的图片格式:{type}',
'image.unknownType': '未知格式',
'access.confirm.title': '确认启用 Full access',
'access.confirm.description': '启用 Full access 后agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
@@ -117,6 +131,20 @@ export const en = {
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'image.dropHint': 'Drop to add images',
'image.pending': 'Pending images',
'image.openOriginal': 'Double-click to view original',
'image.openOriginalLabel': '{label}, double-click to view original',
'image.remove': 'Remove image {name}',
'image.original': 'Original image',
'image.label': 'Image',
'image.loadFailed': 'Image failed to load; click to retry',
'image.loading': 'Loading image…',
'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',
'access.confirm.title': 'Enable Full access?',
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'access.confirm.acknowledge': 'I understand the risks and want to continue',

View File

@@ -63,6 +63,19 @@ interface ImageUrlEntry {
readonly pending: Promise<string>
}
/** Unsupported browser-declared image type, localized by the UI boundary. */
export class UnsupportedImageMediaTypeError extends Error {
/** Browser-declared MIME value, possibly empty. */
readonly mediaType: string
/** @param mediaType - Browser-declared MIME value, possibly empty. */
constructor(mediaType: string) {
super(`unsupported image media type: ${mediaType || '(empty)'}`)
this.name = 'UnsupportedImageMediaTypeError'
this.mediaType = mediaType
}
}
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face, design §5.2). */
@@ -310,7 +323,7 @@ function imageMediaType(value: string): ImageMediaType {
case 'image/gif':
return value
default:
throw new Error(`不支持的图片格式:${value || '未知格式'}`)
throw new UnsupportedImageMediaTypeError(value)
}
}

View File

@@ -1,8 +1,14 @@
import { useEffect, useRef } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ImageLightbox.module.css'
/** Document-level original-image preview opened by an explicit double-click. */
export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
export function ImageLightbox({ src, alt, onClose, t }: {
src: string
alt: string
onClose: () => void
t: ChatViewSlotProps['t']
}) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
@@ -24,11 +30,11 @@ export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string;
className={css.backdrop}
role="dialog"
aria-modal="true"
aria-label="原图预览"
aria-label={t('image.preview')}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label="关闭原图预览" onClick={onClose}>×</button>
<button ref={closeRef} type="button" className={css.close} aria-label={t('image.closePreview')} onClick={onClose}>×</button>
</div>
)
}

View File

@@ -478,25 +478,25 @@ export function InputBar({
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{dragActive && <div className={css.dropHint} role="status"></div>}
{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>}
{attachments.length > 0 && (
<div className={css.attachments} role="group" aria-label="待发送图片">
<div className={css.attachments} role="group" aria-label={t('image.pending')}>
{attachments.map(attachment => (
<div key={attachment.id} className={css.attachment}>
<button
type="button"
className={css.thumbnail}
title="双击查看原图"
title={t('image.openOriginal')}
onDoubleClick={() => { setPreview(attachment) }}
>
<img src={attachment.previewUrl} alt={attachment.file.name || '待发送图片'} />
<img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
</button>
<button
type="button"
className={css.remove}
aria-label={`移除图片 ${attachment.file.name || ''}`}
aria-label={t('image.remove', { name: attachment.file.name })}
onClick={() => {
setDropError(null)
removeImage?.(attachment.id)
@@ -583,7 +583,14 @@ export function InputBar({
</div>
</div>
</div>
{preview !== null && <ImageLightbox src={preview.previewUrl} alt={preview.file.name || '原图'} onClose={closePreview} />}
{preview !== null && (
<ImageLightbox
src={preview.previewUrl}
alt={preview.file.name || t('image.original')}
onClose={closePreview}
t={t}
/>
)}
{footer}
</div>
)

View File

@@ -23,6 +23,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
import type { createChatStore } from '../src/client/stores.ts'
const ROOT = 'root-1' as SessionId
@@ -96,11 +97,12 @@ async function bench() {
const inputSurface = (id: SessionId) => {
const info = runtime.sessions.provideInfo(id)!
const state = info.hooks['input'] as {
getSnapshot: () => { draft: string }
getSnapshot: () => { draft: string; imageIds: readonly DraftAttachmentId[] }
subscribe: (fn: () => void) => () => void
}
const actions = info.props['inputActions'] as {
setDraft: (text: string) => void
addImages: (ids: readonly DraftAttachmentId[]) => boolean
submit: (mode?: 'queue' | 'steer') => void
}
return { state, actions }
@@ -108,7 +110,7 @@ async function bench() {
return {
runtime, feature, slots: runtime.slots, entryOf,
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
sessionFake, layoutFake,
sessionFake, layoutFake, locale,
}
}
@@ -288,6 +290,41 @@ describe('conversation slot inject surface', () => {
await b.runtime.dispose()
})
it('keeps a mixed draft together when the destination refuses its images', async () => {
const b = await bench()
const OTHER = 'mixed-target' as SessionId
await b.runtime.sessions.add({ id: OTHER }, { current: false })
const source = b.inputSurface(ROOT)
const destination = b.inputSurface(OTHER)
const imageId = 'draft-mixed' as DraftAttachmentId
source.actions.setDraft('carry together')
source.actions.addImages([imageId])
const destinationShell = b.composerSurface(OTHER).keyboard as unknown as {
addImages: (ids: readonly DraftAttachmentId[]) => boolean
}
vi.spyOn(destinationShell, 'addImages').mockReturnValue(false)
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
await b.residentSurface(ROOT).selectWorkspace('workspace-mixed' as never)
expect(source.state.getSnapshot()).toMatchObject({
draft: 'carry together',
imageIds: [imageId],
})
expect(destination.state.getSnapshot()).toMatchObject({ draft: '', imageIds: [] })
await b.runtime.dispose()
})
it('localizes browser image-type rejection through the active conversation locale', async () => {
const b = await bench()
b.locale.setLocale('en')
const error = b.composerSurface(ROOT).addImages?.([
new File([Uint8Array.of(1)], 'vector.svg', { type: 'image/svg+xml' }),
])
expect(error).toBe('Unsupported image format: image/svg+xml')
await b.runtime.dispose()
})
it('scopedConversation fails loud when the session resolves no scope', async () => {
const b = await bench()
// The chat-view inject resolves the scoped conversation service at inject

View File

@@ -7,11 +7,12 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
@@ -25,7 +26,7 @@ const attachment = {
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} />)
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const frame = view.getByRole('button', { name: 'history.png双击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
@@ -41,13 +42,23 @@ describe('MessageImage', () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} />)
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
it('renders image controls from the active English dictionary', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown

View File

@@ -9,7 +9,7 @@ import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import { InputHub } from '../src/client/input/hub.ts'
import { ConversationService } from '../src/client/service.ts'
import { ConversationService, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
async function bench(readAttachment?: SessionFace['readAttachment']) {
const runtime = await SlotTestRuntime.create()
@@ -86,12 +86,11 @@ describe('ConversationService', () => {
expect(created).toHaveBeenCalledTimes(11)
const beforeRejectedBatch = created.mock.calls.length
expect(() => {
b.root.createDraftImages([
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
])
}).toThrow('不支持的图片格式image/svg+xml')
expect(() => b.root.createDraftImages([
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
]))
.toThrow(UnsupportedImageMediaTypeError)
expect(created).toHaveBeenCalledTimes(beforeRejectedBatch)
} finally {
created.mockRestore()
@@ -127,6 +126,33 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('restores failed-send images before images added while the request was in flight', async () => {
const b = await bench()
const first = b.root.createDraftImages([
new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
])[0]
const second = b.root.createDraftImages([
new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }),
])[0]
if (first === undefined || second === undefined) throw new Error('draft attachment missing')
const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId)
const request = Promise.withResolvers<{ ok: true; value: { accepted: true } }>()
b.prompt.mockReturnValueOnce(request.promise)
shell.addImages([first.id])
shell.setDraft('describe')
shell.submit('queue')
expect(shell.addImages([second.id])).toBe(true)
expect(shell.snapshot.imageIds).toEqual([second.id])
request.reject(new Error('transport died'))
await vi.waitFor(() => {
expect(shell.snapshot.imageIds).toEqual([first.id, second.id])
})
expect(b.root.draftImages(shell.snapshot.imageIds)).toEqual([first, second])
await b.runtime.dispose()
})
it('does not publish a historical image URL after disposal', async () => {
let resolveRead!: (result: Awaited<ReturnType<SessionFace['readAttachment']>>) => void
const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise<Awaited<ReturnType<SessionFace['readAttachment']>>>(

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: 09f0f53bec81dd559c8da9c94a6b5459019b4ba2
README.zh.md: bba5efaa0d9c9486b7367a6c8db069d10b18ffb5
README.md: 5367795aeb7655f9323ab94d71803049ee452cb0
README.zh.md: c1507f9c25595d83a63326127779c6240f9cc8a7

View File

@@ -20,7 +20,7 @@ Session titles ride the generic projection pair like every other domain — the
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Selection is serialized with image-bearing prompt admission and rejects a text-only target while an image is pending publication or remains in the current derived history; an image removed by compaction no longer blocks selection. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. Image-bearing carriers separately gate text-only model selection until publication or discard: idle without publication retires a claimed queued carrier, but steering retained in the agent outbox remains gated across a failed turn. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.

View File

@@ -20,7 +20,7 @@
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度并替换将在下一提示词组装边界使用的完整目标。模型选择与包含图片的提示词准入串行执行当图片正等待发布或仍存在于当前派生历史中时会拒绝选择纯文本目标被压缩compaction移除的图片不再阻止选择。目录成员关系不构成校验适配器可以解析未列出的模型而不可用路由或不受支持的推理强度会返回 `model-unavailable`
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering中途引导不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering中途引导不进入此 Web 投影。含图片的载体会另行约束纯文本模型选择,直到发布或丢弃:未发布即转入空闲时,已认领的 queued 载体会被退役;但保留在 agent outbox 中的 steering 即使跨越失败轮次也仍受门槛约束。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。

View File

@@ -877,9 +877,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (changed) publishQueue(agent.id)
}),
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
// Idle proves every claimed admission either published (retired by its
// session event) or ended without one; drop the stale gate carriers.
if (status === 'idle') pendingPublication.delete(agent.id)
if (status !== 'idle') return
const pending = pendingPublication.get(agent.id)
if (pending === undefined) return
// A claimed queued prompt disappears when admission ends without
// publication. Steering instead remains staged in the agent outbox
// across a failed turn, so retain it until steering/message or discard.
const kept = pending.filter(item => item.placement === 'steering')
if (kept.length === 0) pendingPublication.delete(agent.id)
else pendingPublication.set(agent.id, kept)
}),
ctx.on('session/disposed', (session: Session) => {
queuedMirror.delete(session.id)

View File

@@ -404,6 +404,11 @@ describe('Web session model selection', () => {
ctx.emit('agent/inbox/dequeue', agent, steeringItem)
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
// A failed turn returns idle while leaving steering staged in the outbox;
// only publication or discard may retire this carrier.
ctx.emit('agent/status', agent, 'idle')
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
// Publication hands the gate over to the durable surface.
agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' })
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)