fix(gui): remove temporary multimodal routing state
This commit is contained in:
@@ -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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 7c14a8bdbb6d8164b2a6eb4432d196936271e4d1
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: e9d7f8ebe78aa76285367e83374a6ace3a9eef21
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: dff89f1ab5124eaa96e5d95214911a82be022d16
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: c518edd5c15ebd2a6b776a899049870a4274b5d7
|
||||
|
||||
@@ -120,9 +120,9 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida
|
||||
|
||||
Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability.
|
||||
|
||||
The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the host-default active model and the image limits into `SessionsService`; the composer applies only the deployment limits before allocating object URLs or base64. Model capability is deliberately not gated client-side: the handshake snapshot cannot represent a session's current target after `session.selectModel`, so the host preflight is the sole capability authority and its rejection renders through the composer error strip. Decoded-pixel validation and the session's actual route remain authoritative on the host.
|
||||
The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip.
|
||||
|
||||
The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped Web assembly reaches it through `dsh web --provider <name> --model <id>`: the yml pi-ai row already routes openai/anthropic with ambient credentials, and only a catalog provider absent from that row is mounted dynamically; the DeepSeek-only default remains text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
|
||||
The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
|
||||
|
||||
Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
|
||||
|
||||
@@ -138,7 +138,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state
|
||||
|
||||
### Limits and trust boundaries
|
||||
|
||||
Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. The client connection carrier independently caps buffered API request bodies, deriving the cap from the aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end.
|
||||
Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier independently caps buffered API request bodies, deriving the cap from the host attachment service's aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end.
|
||||
|
||||
Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser.
|
||||
|
||||
|
||||
@@ -120,9 +120,9 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规
|
||||
|
||||
模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。
|
||||
|
||||
宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把宿主默认的当前模型和图片限制投影到 `SessionsService`;composer 在分配对象 URL 或 base64 前只应用部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。
|
||||
宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。
|
||||
|
||||
Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider <name> --model <id>` 到达这条路径:yml 的 pi-ai row 已用环境凭据路由 openai/anthropic,只有该 row 之外的目录 provider 才会动态挂载;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。
|
||||
Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。
|
||||
|
||||
核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
|
||||
|
||||
@@ -138,7 +138,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为
|
||||
|
||||
### 限制与信任边界
|
||||
|
||||
第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据图片总量限制加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。
|
||||
第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据宿主附件服务的图片总量限制,加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。
|
||||
|
||||
格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。
|
||||
|
||||
|
||||
@@ -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 apps/cli/README.md
|
||||
README.md: 337370f6b3738cef22bc826501633b9aca1f8c62
|
||||
README.zh.md: e1bdad1745aed4f9f6cd08d9e9c43bcadcec7711
|
||||
README.md: 93c36d18abd06bbd7a80c918f520b92489180395
|
||||
README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd
|
||||
|
||||
@@ -14,7 +14,7 @@ The TUI surface:
|
||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
||||
|
||||
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider <name> --model <id>` selects that route: for the shipped roster (openai/anthropic) the already-mounted yml pi-ai row serves it with provider-native ambient credentials, while a pi-ai catalog provider absent from that row is mounted dynamically; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
|
||||
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ TUI 界面:
|
||||
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
|
||||
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
|
||||
|
||||
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider <name> --model <id>` 选择对应路由:出货清单内的 provider(openai/anthropic)由 yml 中已挂载的 pi-ai row 以提供方原生环境凭据直接服务,只有该 row 之外的 pi-ai catalog provider 才会动态挂载;默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
|
||||
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
|
||||
|
||||
|
||||
@@ -61,89 +61,6 @@ export function resolveLanTrust(
|
||||
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
||||
}
|
||||
|
||||
/** One provider/model source layer for {@link resolveLlmRoute}, in override order. */
|
||||
export interface LlmRouteInput {
|
||||
/** CLI flag values (highest precedence). */
|
||||
cli: { provider?: string | undefined; model?: string | undefined }
|
||||
/** Profile-json values (parsed JSON — validated here, the config boundary). */
|
||||
profile: { provider?: unknown; model?: unknown }
|
||||
/** The api-gateway yml row's config values (deployment defaults). */
|
||||
gateway: { provider?: unknown; model?: unknown }
|
||||
/** Providers the shipped yml already routes through its static pi-ai row. */
|
||||
ymlPiAiProviders: readonly string[]
|
||||
}
|
||||
|
||||
/** The boot's resolved LLM routing decision. */
|
||||
export interface LlmRoute {
|
||||
/** Effective api-gateway provider. */
|
||||
provider: string
|
||||
/** Pi-ai provider to mount dynamically; undefined when DeepSeek or a yml-routed provider serves the request. */
|
||||
dynamicPiAiProvider: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the boot's LLM route from the layered provider/model sources.
|
||||
* A non-DeepSeek provider requires a model set at least as explicitly as the
|
||||
* provider itself (flag/profile) — origin decides, never a comparison against
|
||||
* any deployment's default model value, so editing the yml default cannot
|
||||
* silently disarm the guard. Providers the shipped yml pi-ai row already
|
||||
* routes are NOT mounted again: `LlmService.registerAdapter` rejects
|
||||
* duplicate routes, so the gateway provider/model patch alone selects them.
|
||||
* @param input - the layered provider/model sources and the yml pi-ai roster.
|
||||
* @returns the effective provider and the dynamic pi-ai mount decision.
|
||||
*/
|
||||
export function resolveLlmRoute(input: LlmRouteInput): LlmRoute {
|
||||
const provider = input.cli.provider ?? input.profile.provider ?? input.gateway.provider
|
||||
if (typeof provider !== 'string' || provider === '') {
|
||||
throw new Error('dsh: api-gateway provider must be a non-empty string')
|
||||
}
|
||||
if (provider !== 'deepseek') {
|
||||
const providerFromYml = input.cli.provider === undefined && input.profile.provider === undefined
|
||||
// A yml-set provider trusts its own row pairing; an override must bring
|
||||
// its model along instead of inheriting the yml default's.
|
||||
const model = providerFromYml
|
||||
? input.gateway.model
|
||||
: input.cli.model ?? input.profile.model
|
||||
if (typeof model !== 'string' || model === '') {
|
||||
throw new Error(`dsh: provider ${provider} requires an explicit model`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
dynamicPiAiProvider: provider === 'deepseek' || input.ymlPiAiProviders.includes(provider)
|
||||
? undefined
|
||||
: provider,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass parse of an include yml's top-level entry rows (id → row). Exported
|
||||
* so tests can pin the shipped tree's real row coupling instead of literals.
|
||||
* @param configPath - absolute path of the include cordis.yml.
|
||||
* @returns row map keyed by entry id.
|
||||
*/
|
||||
export function parseIncludeYmlRows(configPath: string): Map<string, { config?: unknown }> {
|
||||
const doc = yaml.load(readFileSync(configPath, 'utf8'), { schema: includeYamlSchema })
|
||||
if (!Array.isArray(doc)) throw new Error(`dsh: ${configPath} is not a top-level entry list`)
|
||||
const rows = new Map<string, { config?: unknown }>()
|
||||
for (const row of doc as { id?: string; config?: unknown }[]) {
|
||||
if (typeof row.id === 'string') rows.set(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers the yml's static pi-ai row routes — the roster {@link resolveLlmRoute} reuses.
|
||||
* @param rows - parsed include rows.
|
||||
* @returns provider ids in row order (empty when the row is absent).
|
||||
*/
|
||||
export function ymlPiAiProvidersOf(rows: ReadonlyMap<string, { config?: unknown }>): string[] {
|
||||
const config = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | undefined
|
||||
return (config?.providers ?? [])
|
||||
.map(entry => entry.provider)
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
}
|
||||
|
||||
/** One profile-json key mapped onto a yml row's config field. */
|
||||
interface ProfileMapping {
|
||||
jsonPath: string
|
||||
@@ -198,14 +115,6 @@ export interface AppCLIEntryOptions {
|
||||
port?: number
|
||||
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
|
||||
workspaceRoot?: string
|
||||
/**
|
||||
* Host default provider override. Providers the shipped yml pi-ai row
|
||||
* already routes are reused; only a provider absent from that row mounts
|
||||
* pi-ai dynamically.
|
||||
*/
|
||||
provider?: string
|
||||
/** Host default model override. */
|
||||
model?: string
|
||||
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
@@ -229,7 +138,6 @@ export class AppCLIEntry {
|
||||
lanAddresses: readonly string[] = []
|
||||
|
||||
private patches: PatchOptions[] = []
|
||||
private piAiProvider: string | undefined
|
||||
|
||||
constructor(private readonly options: AppCLIEntryOptions) {}
|
||||
|
||||
@@ -290,17 +198,6 @@ export class AppCLIEntry {
|
||||
if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
|
||||
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
|
||||
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
|
||||
if (this.options.provider !== undefined) put('api-gateway', 'provider', this.options.provider)
|
||||
if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model)
|
||||
|
||||
const gatewayConfig = rows.get('api-gateway')?.config as Record<string, unknown> | undefined
|
||||
const route = resolveLlmRoute({
|
||||
cli: { provider: this.options.provider, model: this.options.model },
|
||||
profile: { provider: profile.provider, model: profile.model },
|
||||
gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model },
|
||||
ymlPiAiProviders: ymlPiAiProvidersOf(rows),
|
||||
})
|
||||
this.piAiProvider = route.dynamicPiAiProvider
|
||||
|
||||
// Source 2b: authorities for the /api browser-trust fence (rationale on
|
||||
// resolveLanTrust).
|
||||
@@ -333,12 +230,6 @@ export class AppCLIEntry {
|
||||
...this.patches.length > 0 ? { patches: this.patches } : {},
|
||||
},
|
||||
})
|
||||
if (this.piAiProvider !== undefined) {
|
||||
await ctx.loader.create({
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
config: { providers: [{ provider: this.piAiProvider }] },
|
||||
})
|
||||
}
|
||||
if (this.options.dev) {
|
||||
await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
|
||||
}
|
||||
@@ -373,7 +264,13 @@ export class AppCLIEntry {
|
||||
|
||||
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
|
||||
private parseYmlRows(): Map<string, { config?: unknown }> {
|
||||
return parseIncludeYmlRows(this.options.configPath)
|
||||
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
|
||||
if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
|
||||
const rows = new Map<string, { config?: unknown }>()
|
||||
for (const row of doc as { id?: string; config?: unknown }[]) {
|
||||
if (typeof row.id === 'string') rows.set(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Profile json under cwd; read-only — never created here, absent = no user config. */
|
||||
|
||||
@@ -33,7 +33,6 @@ interface HeadlessInvocation {
|
||||
* value fails loud at boot). `port` is `Number`-coerced only because the schema
|
||||
* wants a number, not a string. `dev` mounts the client HMR driver;
|
||||
* `workspaceRoot` is the parent directory for name-created workspaces.
|
||||
* `provider` and `model` override the host's default model route.
|
||||
*/
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
@@ -41,8 +40,6 @@ interface WebInvocation {
|
||||
port?: number
|
||||
dev: boolean
|
||||
workspaceRoot?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
@@ -56,8 +53,6 @@ interface WebOptions {
|
||||
port?: string
|
||||
dev?: boolean
|
||||
workspaceRoot?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
trustedHost?: string[]
|
||||
}
|
||||
|
||||
@@ -74,8 +69,6 @@ function resolveWeb(options: WebOptions): WebInvocation {
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
dev: options.dev === true,
|
||||
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
|
||||
...options.provider !== undefined && { provider: options.provider },
|
||||
...options.model !== undefined && { model: options.model },
|
||||
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
|
||||
}
|
||||
}
|
||||
@@ -128,8 +121,6 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
|
||||
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
|
||||
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
|
||||
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
|
||||
.option('--provider <name>', 'override the host default provider')
|
||||
.option('--model <id>', 'override the host default model')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.action((options: WebOptions) => {
|
||||
// Commander parses the parent (default-surface) options on either side of
|
||||
|
||||
@@ -35,8 +35,6 @@ switch (invocation.mode) {
|
||||
invocation.port,
|
||||
invocation.dev,
|
||||
invocation.workspaceRoot,
|
||||
invocation.provider,
|
||||
invocation.model,
|
||||
invocation.trustedHosts,
|
||||
)
|
||||
break
|
||||
|
||||
@@ -22,8 +22,6 @@ const LOOPBACK_HOST = '127.0.0.1'
|
||||
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
|
||||
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
|
||||
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
|
||||
* @param provider - provider override, or `undefined` to keep the profile/config route.
|
||||
* @param model - model override, or `undefined` to keep the profile/config route.
|
||||
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
|
||||
*/
|
||||
export async function runWeb(
|
||||
@@ -31,8 +29,6 @@ export async function runWeb(
|
||||
port: number | undefined,
|
||||
dev: boolean,
|
||||
workspaceRoot: string | undefined,
|
||||
provider: string | undefined,
|
||||
model: string | undefined,
|
||||
trustedHosts: string[] | undefined,
|
||||
): Promise<void> {
|
||||
const entry = new AppCLIEntry({
|
||||
@@ -41,8 +37,6 @@ export async function runWeb(
|
||||
...host !== undefined && { host },
|
||||
...port !== undefined && { port },
|
||||
...workspaceRoot !== undefined && { workspaceRoot },
|
||||
...provider !== undefined && { provider },
|
||||
...model !== undefined && { model },
|
||||
...trustedHosts !== undefined && { trustedHosts },
|
||||
})
|
||||
const { ctx, port: boundPort } = await entry.run()
|
||||
|
||||
@@ -35,15 +35,12 @@ describe('parseDshArgs', () => {
|
||||
// at boot); the adapter only coerces the port string to a number.
|
||||
expect(parse([
|
||||
'web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w',
|
||||
'--provider', 'anthropic', '--model', 'claude-opus-4-8',
|
||||
])).toEqual({
|
||||
mode: 'web',
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
dev: true,
|
||||
workspaceRoot: '/w',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-8',
|
||||
})
|
||||
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
@@ -65,6 +62,8 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['web', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['web', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
|
||||
expect(exitCode(['web', '--provider', 'anthropic'])).toBe(1)
|
||||
expect(exitCode(['web', '--model', 'claude-opus-4-8'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for --help (disclosing web) and --version', () => {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseIncludeYmlRows, resolveLlmRoute, ymlPiAiProvidersOf } from '../src/app-cli-entry.ts'
|
||||
|
||||
/** The shipped yml shape: DeepSeek gateway default plus a pi-ai row routing openai/anthropic. */
|
||||
const SHIPPED = {
|
||||
gateway: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
ymlPiAiProviders: ['openai', 'anthropic'],
|
||||
}
|
||||
|
||||
describe('resolveLlmRoute', () => {
|
||||
it('keeps the DeepSeek default without any dynamic mount', () => {
|
||||
expect(resolveLlmRoute({ cli: {}, profile: {}, ...SHIPPED }))
|
||||
.toEqual({ provider: 'deepseek', dynamicPiAiProvider: undefined })
|
||||
})
|
||||
|
||||
it('reuses the yml pi-ai row for providers it already routes (no duplicate adapter)', () => {
|
||||
expect(resolveLlmRoute({
|
||||
cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, ...SHIPPED,
|
||||
})).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined })
|
||||
})
|
||||
|
||||
it('mounts pi-ai dynamically only for providers absent from the yml row', () => {
|
||||
expect(resolveLlmRoute({
|
||||
cli: { provider: 'google', model: 'gemini-3-pro' }, profile: {}, ...SHIPPED,
|
||||
})).toEqual({ provider: 'google', dynamicPiAiProvider: 'google' })
|
||||
})
|
||||
|
||||
it('requires an explicit model wherever the provider override came from, by origin', () => {
|
||||
// CLI provider with no CLI/profile model: the yml DeepSeek default must not leak in.
|
||||
expect(() => resolveLlmRoute({ cli: { provider: 'anthropic' }, profile: {}, ...SHIPPED }))
|
||||
.toThrow(/provider anthropic requires an explicit model/)
|
||||
// Profile provider paired with a profile model is explicit enough.
|
||||
expect(resolveLlmRoute({
|
||||
cli: {}, profile: { provider: 'openai', model: 'gpt-5' }, ...SHIPPED,
|
||||
})).toEqual({ provider: 'openai', dynamicPiAiProvider: undefined })
|
||||
// Profile provider with only the yml default model: same gap, same refusal.
|
||||
expect(() => resolveLlmRoute({ cli: {}, profile: { provider: 'openai' }, ...SHIPPED }))
|
||||
.toThrow(/provider openai requires an explicit model/)
|
||||
})
|
||||
|
||||
it('trusts a yml-set non-DeepSeek provider only when its own row carries the model', () => {
|
||||
expect(resolveLlmRoute({
|
||||
cli: {}, profile: {},
|
||||
gateway: { provider: 'anthropic', model: 'claude-opus-4-8' },
|
||||
ymlPiAiProviders: ['openai', 'anthropic'],
|
||||
})).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined })
|
||||
expect(() => resolveLlmRoute({
|
||||
cli: {}, profile: {},
|
||||
gateway: { provider: 'anthropic' },
|
||||
ymlPiAiProviders: ['openai', 'anthropic'],
|
||||
})).toThrow(/provider anthropic requires an explicit model/)
|
||||
})
|
||||
|
||||
it('reuses the SHIPPED cordis.yml roster — the coupling that prevents the duplicate-adapter boot failure', () => {
|
||||
// Parsed from the real file through the production extraction, not a
|
||||
// literal roster: renaming the `llm-pi-ai` row or its providers field
|
||||
// must fail here, because composePatches reads exactly these shapes.
|
||||
const rows = parseIncludeYmlRows(join(import.meta.dirname, '..', 'cordis.yml'))
|
||||
const roster = ymlPiAiProvidersOf(rows)
|
||||
expect(roster).toEqual(['openai', 'anthropic'])
|
||||
const gateway = (rows.get('api-gateway')?.config ?? {}) as { provider?: unknown; model?: unknown }
|
||||
expect(resolveLlmRoute({
|
||||
cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {},
|
||||
gateway, ymlPiAiProviders: roster,
|
||||
})).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined })
|
||||
})
|
||||
|
||||
it('fails loud on a missing or empty provider', () => {
|
||||
expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] }))
|
||||
.toThrow(/provider must be a non-empty string/)
|
||||
expect(() => resolveLlmRoute({ cli: { provider: '' }, profile: {}, ...SHIPPED }))
|
||||
.toThrow(/provider must be a non-empty string/)
|
||||
})
|
||||
})
|
||||
@@ -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: 3ceb4f56b672170f7a68532718fdd6e249c7b1bc
|
||||
README.zh.md: fdb67792f479b6150f3918db825cfaf6efcd12e5
|
||||
README.md: 6f4d8bf15fb581d184a1bb36c912a88a319adf26
|
||||
README.zh.md: 6ae5291aee8e7e12d78a9c06c2ed9a1432b4f2a0
|
||||
|
||||
@@ -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 publishes its validated `host.describe` value through `onDescription` before `onConnected`; a business-error response fails the generation like a transport error. 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 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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会先通过 `onDescription` 发布经过校验的 `host.describe` 值,再调用 `onConnected`;业务错误响应会像传输错误一样使该连接代失败。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
|
||||
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
|
||||
@@ -44,8 +44,6 @@ export type ConnectionState = 'connected' | 'reconnecting'
|
||||
export interface ConnectionSinks {
|
||||
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
|
||||
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
|
||||
/** Latest successful host capability snapshot for this connection generation. */
|
||||
onDescription?: (description: HostDescription) => void
|
||||
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
|
||||
onConnected?: () => void
|
||||
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
||||
@@ -144,7 +142,6 @@ export class ConnectionController {
|
||||
}
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.callSink(() => { this.sinks.onDescription?.(descriptionResult.value) })
|
||||
this.emitState('connected')
|
||||
this.callSink(this.sinks.onConnected)
|
||||
} catch {
|
||||
|
||||
@@ -1260,20 +1260,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
cwd: '/tmp/fixture',
|
||||
provider: 'fixture',
|
||||
model: 'fx-vision',
|
||||
activeModel: {
|
||||
provider: 'fixture',
|
||||
id: 'fx-vision',
|
||||
name: 'Fixture Vision',
|
||||
inputModalities: ['text', 'image'],
|
||||
outputModalities: ['text', 'image'],
|
||||
},
|
||||
imageLimits: {
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxImagesPerMessage: 10,
|
||||
maxMessageImageBytes: 20 * 1024 * 1024,
|
||||
maxImagePixels: 40_000_000,
|
||||
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
},
|
||||
attachedSessions,
|
||||
}),
|
||||
// Deterministic native pick: the keyless lanes drive the full
|
||||
|
||||
@@ -23,11 +23,9 @@ describe('connection lifecycle', () => {
|
||||
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
const descriptions: string[] = []
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, {
|
||||
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
|
||||
onDescription: description => descriptions.push(description.version),
|
||||
onConnected: () => { connected++ },
|
||||
}, FAST)
|
||||
controller.start()
|
||||
@@ -36,7 +34,6 @@ describe('connection lifecycle', () => {
|
||||
api.pushMux(subscribedFrame())
|
||||
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
|
||||
expect(api.callsOf('host.describe')).toHaveLength(1)
|
||||
expect(descriptions).toEqual(['0-fake'])
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { HostDescription, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
@@ -22,11 +22,6 @@ export interface ISessions {
|
||||
readonly list: ObservableSnapshot<SessionListState>
|
||||
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
/**
|
||||
* Read the latest successfully received host capability description.
|
||||
* @returns host capabilities, or undefined before the first successful handshake.
|
||||
*/
|
||||
hostDescription(): HostDescription | undefined
|
||||
/**
|
||||
* Select a session as current.
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
|
||||
@@ -178,7 +178,6 @@ export function apply(ctx: Context): void {
|
||||
console.error('[web-runtime] history host-frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onDescription: (description) => { sessions.handleDescription(description) },
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type {
|
||||
HostDescription, IApiClient, RpcError, SessionId, WorkspaceId,
|
||||
IApiClient, RpcError, SessionId, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
@@ -191,7 +191,6 @@ export class SessionsService implements ISessions {
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
private description: HostDescription | undefined
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
@@ -231,22 +230,6 @@ export class SessionsService implements ISessions {
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the latest successful connection-generation host description.
|
||||
* @param description - capability and deployment snapshot from `host.describe`.
|
||||
*/
|
||||
handleDescription(description: HostDescription): void {
|
||||
this.description = description
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest host capability snapshot.
|
||||
* @returns the last successful description, or undefined before connection.
|
||||
*/
|
||||
hostDescription(): HostDescription | undefined {
|
||||
return this.description
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider: every session-scope slot
|
||||
* component receives the contributed members as standard props (`hooks`
|
||||
|
||||
@@ -74,12 +74,6 @@ describe('runtime client apply', () => {
|
||||
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
|
||||
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
|
||||
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
|
||||
bench.sinks?.onDescription?.({ version: '0', cwd: '/f', attachedSessions: 0 })
|
||||
expect((sessions as { hostDescription(): unknown }).hostDescription()).toEqual({
|
||||
version: '0',
|
||||
cwd: '/f',
|
||||
attachedSessions: 0,
|
||||
})
|
||||
bench.sinks?.onConnected?.()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
@@ -197,14 +196,6 @@ export class TestSessions implements ISessions {
|
||||
this.list.subscribe(() => { this.channel.publishCurrent() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Test runtime has no host handshake unless a fixture explicitly supplies one.
|
||||
* @returns undefined.
|
||||
*/
|
||||
hostDescription(): HostDescription | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a session from a fixture and (by default) make it current.
|
||||
* @param fixture - identity + snapshot/summary overrides + behavior face.
|
||||
|
||||
@@ -472,8 +472,6 @@ describe('fixture session face', () => {
|
||||
expect(() => bare.command()).toThrow(/command is not stubbed/)
|
||||
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
||||
expect(() => bare.rename()).toThrow(/rename is not stubbed/)
|
||||
// No host handshake exists in the bench unless a fixture supplies one.
|
||||
expect(runtime.sessions.hostDescription()).toBeUndefined()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -191,9 +191,9 @@ export function apply(ctx: Context): void {
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
addImages: (files, current) => {
|
||||
addImages: (files) => {
|
||||
try {
|
||||
const images = conversation.createDraftImages(files, current)
|
||||
const images = conversation.createDraftImages(files)
|
||||
shell.addImages(images.map(image => image.id))
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -273,7 +273,7 @@ export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||
keyboard: ComposerKeyboard
|
||||
/** Create browser previews and append their ids to the session input state. */
|
||||
addImages: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null
|
||||
addImages: (files: readonly File[]) => string | null
|
||||
/** Release one browser preview and remove its id from the session input state. */
|
||||
removeImage: (id: string) => void
|
||||
/** Resolve ordered input-state ids to browser-owned draft attachments. */
|
||||
|
||||
@@ -46,13 +46,9 @@ export interface IConversation {
|
||||
/**
|
||||
* Create runtime-only draft attachments and preview URLs.
|
||||
* @param files - browser-owned image files.
|
||||
* @param current - images already present in the composer.
|
||||
* @returns ordered descriptors for the input state.
|
||||
*/
|
||||
createDraftImages(
|
||||
files: readonly File[],
|
||||
current?: readonly ComposerAttachment[],
|
||||
): readonly ComposerAttachment[]
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[]
|
||||
/**
|
||||
* Resolve ordered draft ids to runtime-owned attachments.
|
||||
* @param ids - ordered composer attachment ids.
|
||||
@@ -171,7 +167,6 @@ export class ConversationService extends Service implements IConversation {
|
||||
mode: 'queue' | 'steer',
|
||||
images: readonly File[],
|
||||
): Promise<void> {
|
||||
this.validateImages(images, [])
|
||||
const uploaded = await this.serializeImages(images)
|
||||
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
|
||||
const result = await session.prompt(content, mode)
|
||||
@@ -181,14 +176,10 @@ export class ConversationService extends Service implements IConversation {
|
||||
/**
|
||||
* Create runtime-only draft attachments and their object URLs.
|
||||
* @param files - browser-owned image files.
|
||||
* @param current - images already present in the same composer.
|
||||
* @returns ordered attachment descriptors whose ids may enter the input state.
|
||||
*/
|
||||
createDraftImages(
|
||||
files: readonly File[],
|
||||
current: readonly ComposerAttachment[] = [],
|
||||
): readonly ComposerAttachment[] {
|
||||
this.validateImages(files, current)
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
|
||||
for (const file of files) imageMediaType(file.type)
|
||||
return files.map((file) => {
|
||||
const attachment = new BrowserDraftAttachment(file)
|
||||
this.draftAttachments.set(attachment.id, attachment)
|
||||
@@ -324,40 +315,6 @@ export class ConversationService extends Service implements IConversation {
|
||||
return sessions
|
||||
}
|
||||
|
||||
/** Apply host-advertised fast-path checks before any object URL or base64 allocation. */
|
||||
private validateImages(
|
||||
files: readonly File[],
|
||||
current: readonly ComposerAttachment[],
|
||||
): void {
|
||||
if (files.length === 0 && current.length === 0) return
|
||||
// Deployment-wide limits only. Model capability is deliberately NOT
|
||||
// checked here: the handshake's activeModel is the host default, not the
|
||||
// session's current target (session.selectModel never refreshes it), so a
|
||||
// client-side modality gate refuses sessions the host would accept and
|
||||
// vice versa. The host preflight on session.prompt is the authority; its
|
||||
// rejection renders through the composer error strip.
|
||||
const description = this.requireSessions().hostDescription()
|
||||
const limits = description?.imageLimits
|
||||
const all = [...current.map(attachment => attachment.file), ...files]
|
||||
if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
|
||||
throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`)
|
||||
}
|
||||
let totalBytes = 0
|
||||
for (const file of all) {
|
||||
const mediaType = imageMediaType(file.type)
|
||||
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
|
||||
throw new Error(`当前部署不支持 ${mediaType} 图片`)
|
||||
}
|
||||
if (limits !== undefined && file.size > limits.maxImageBytes) {
|
||||
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
|
||||
}
|
||||
totalBytes += file.size
|
||||
}
|
||||
if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
|
||||
throw new Error('图片总大小超过单条消息限制')
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert browser files to the prompt wire's canonical base64 image parts. */
|
||||
private serializeImages(images: readonly File[]): Promise<Parameters<SessionFace['prompt']>[0]> {
|
||||
return Promise.all(images.map(async file => ({
|
||||
|
||||
@@ -234,7 +234,7 @@ export function InputBar({
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
if (files.length > 0) {
|
||||
setDropError(addImages(files, attachments))
|
||||
setDropError(addImages(files))
|
||||
}
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') {
|
||||
@@ -290,7 +290,7 @@ export function InputBar({
|
||||
if (locked || machineBusy) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
if (dropped.length === 0) return
|
||||
setDropError(addImages(dropped, attachments))
|
||||
setDropError(addImages(dropped))
|
||||
}
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
@@ -49,7 +49,7 @@ interface BenchOptions {
|
||||
leftItems?: React.ReactNode
|
||||
rightItems?: React.ReactNode
|
||||
attachments?: readonly ComposerAttachment[]
|
||||
addImages?: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null
|
||||
addImages?: (files: readonly File[]) => string | null
|
||||
}
|
||||
|
||||
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
|
||||
@@ -464,7 +464,7 @@ describe('image draft rail', () => {
|
||||
getData: () => '同时粘贴的文字',
|
||||
},
|
||||
})
|
||||
expect(addImages).toHaveBeenCalledWith([image], [])
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
||||
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
@@ -494,7 +494,7 @@ describe('image draft rail', () => {
|
||||
expect(dataTransfer.dropEffect).toBe('copy')
|
||||
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
expect(addImages).toHaveBeenCalledWith([image], [])
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
})
|
||||
|
||||
it('ignores unsupported dropped files and refuses drops while locked', () => {
|
||||
@@ -507,7 +507,7 @@ describe('image draft rail', () => {
|
||||
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
|
||||
})
|
||||
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
|
||||
expect(addImages).toHaveBeenCalledWith([documentFile], [])
|
||||
expect(addImages).toHaveBeenCalledWith([documentFile])
|
||||
|
||||
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
|
||||
const locked = bench({ disabled: true, addImages })
|
||||
|
||||
@@ -69,6 +69,31 @@ describe('ConversationService', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('checks media type before preview allocation and leaves deployment limits to the host', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockImplementation(file => `blob:${(file as File).name}`)
|
||||
try {
|
||||
const files = Array.from(
|
||||
{ length: 11 },
|
||||
(_, index) => new File([Uint8Array.of(index)], `${index}.png`, { type: 'image/png' }),
|
||||
)
|
||||
expect(b.root.createDraftImages(files)).toHaveLength(11)
|
||||
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(created).toHaveBeenCalledTimes(beforeRejectedBatch)
|
||||
} finally {
|
||||
created.mockRestore()
|
||||
}
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases in-flight send images when the scope dies before the failure lands', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1')
|
||||
|
||||
@@ -1419,24 +1419,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
host: {
|
||||
async describe(request) {
|
||||
const activeModel = (await ctx.llm.listModels(defaults.provider))
|
||||
.find(model => model.id === defaults.model)
|
||||
describe(request) {
|
||||
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
|
||||
return ok(request, {
|
||||
return Promise.resolve(ok(request, {
|
||||
version: '0.0.1',
|
||||
// Same source as session.create's fallback: the UI's default project
|
||||
// must match where an unspecified-cwd session actually lands.
|
||||
cwd: defaults.cwd,
|
||||
provider: defaults.provider,
|
||||
model: defaults.model,
|
||||
...activeModel === undefined ? {} : { activeModel },
|
||||
imageLimits: {
|
||||
...ctx.attachments.imageLimits,
|
||||
mediaTypes: [...ctx.attachments.imageLimits.mediaTypes],
|
||||
},
|
||||
attachedSessions: ctx.agents.list().length,
|
||||
})
|
||||
}))
|
||||
},
|
||||
|
||||
async pickDirectory(request, signal) {
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { ModelModality } from '@deepseek-ai/dsh-llm'
|
||||
import type { DirectoryEntry } from './host.ts'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { imageMediaTypeSchema } from './sessions.schema.ts'
|
||||
|
||||
/** Merge-extensible modality passthrough: declaration merging cannot extend a runtime Zod union. */
|
||||
const modalitySchema = z.string() as unknown as z.ZodType<ModelModality>
|
||||
|
||||
/** host.describe request payload (empty object literal). */
|
||||
export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.describe'>>>
|
||||
@@ -21,21 +16,6 @@ export const hostDescribeValueSchema = z.object({
|
||||
cwd: z.string(),
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
activeModel: z.object({
|
||||
provider: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputModalities: z.array(modalitySchema).optional(),
|
||||
outputModalities: z.array(modalitySchema).optional(),
|
||||
}).optional(),
|
||||
imageLimits: 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(imageMediaTypeSchema),
|
||||
}).optional(),
|
||||
attachedSessions: z.number().int().nonnegative(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import type { LlmModelInfo } from '@deepseek-ai/dsh-llm/types'
|
||||
|
||||
/** One directory row of a listing: a child entry or a breadcrumb ancestor. */
|
||||
export interface DirectoryEntry {
|
||||
@@ -49,10 +47,6 @@ export interface HostApi {
|
||||
cwd: string
|
||||
provider?: string
|
||||
model?: string
|
||||
/** Catalog entry for the active route; absent means its capabilities are unknown. */
|
||||
activeModel?: LlmModelInfo
|
||||
/** Resolved authoritative image-upload limits. */
|
||||
imageLimits?: ImageAttachmentLimits
|
||||
attachedSessions: number
|
||||
}>>
|
||||
|
||||
|
||||
@@ -259,40 +259,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect((await c.host.describe({})).result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips a declaration-merged model modality through host.describe', async () => {
|
||||
const c = client(fakeApi({
|
||||
hostDescription: {
|
||||
version: 'v',
|
||||
cwd: '/w',
|
||||
activeModel: {
|
||||
provider: 'future',
|
||||
id: 'audio-model',
|
||||
name: 'Audio Model',
|
||||
inputModalities: ['text', 'audio'],
|
||||
outputModalities: ['audio'],
|
||||
},
|
||||
attachedSessions: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
const response = await c.host.describe({})
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
version: 'v',
|
||||
cwd: '/w',
|
||||
activeModel: {
|
||||
provider: 'future',
|
||||
id: 'audio-model',
|
||||
name: 'Audio Model',
|
||||
inputModalities: ['text', 'audio'],
|
||||
outputModalities: ['audio'],
|
||||
},
|
||||
attachedSessions: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips the native picker without the default unary timeout', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request) => {
|
||||
|
||||
@@ -242,32 +242,17 @@ describe('sessions domain schemas', () => {
|
||||
})
|
||||
|
||||
describe('host domain schemas', () => {
|
||||
it('validates describe request/value and preserves merge-extensible modalities', () => {
|
||||
it('validates describe request/value', () => {
|
||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
||||
const value = hostDescribeValueSchema.parse({
|
||||
version: '1',
|
||||
cwd: '/x',
|
||||
provider: 'p',
|
||||
model: 'm',
|
||||
activeModel: {
|
||||
provider: 'p',
|
||||
id: 'm',
|
||||
name: 'Model',
|
||||
inputModalities: ['text', 'audio'],
|
||||
outputModalities: ['text', 'audio'],
|
||||
},
|
||||
attachedSessions: 2,
|
||||
})
|
||||
expect(value.attachedSessions).toBe(2)
|
||||
expect(value.activeModel?.inputModalities).toEqual(['text', 'audio'])
|
||||
expect(value.activeModel?.outputModalities).toEqual(['text', 'audio'])
|
||||
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
|
||||
expect(() => hostDescribeValueSchema.parse({
|
||||
version: '1',
|
||||
cwd: '/x',
|
||||
activeModel: { provider: 'p', id: 'm', name: 'Model', inputModalities: [{ type: 'audio' }] },
|
||||
attachedSessions: 0,
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('validates the browse listing/creation payloads', () => {
|
||||
|
||||
Reference in New Issue
Block a user