refactor: narrow web image input v1

This commit is contained in:
Tianyi Cui
2026-07-29 23:18:57 +08:00
parent 8348a409aa
commit 3a4c436d83
69 changed files with 280 additions and 613 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 apps/cli/README.md
README.md: 337370f6b3738cef22bc826501633b9aca1f8c62
README.zh.md: e1bdad1745aed4f9f6cd08d9e9c43bcadcec7711
README.md: 467c525361d003fb1638f5812a6524eaae84b7a8
README.zh.md: 579cafd04e67aba42ad7d0ffa2105d33554fe1cb

View File

@@ -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. `dsh web --provider <name> --model <id>` selects a route already registered by that composition; the shipped yml pi-ai row provides openai/anthropic with provider-native ambient credentials, while 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 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.

View File

@@ -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>` 选择对应路由:出货清单内的 provideropenai/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 web --provider <name> --model <id>` 选择该组合已经注册的路由;交付的 yml pi-ai row 使用提供方原生环境凭据提供 openai/anthropic 路由,而默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `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`

View File

@@ -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,11 +115,7 @@ 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.
*/
/** Host default provider override; the shipped tree must register the route. */
provider?: string
/** Host default model override. */
model?: string
@@ -229,8 +142,6 @@ export class AppCLIEntry {
lanAddresses: readonly string[] = []
private patches: PatchOptions[] = []
private piAiProvider: string | undefined
constructor(private readonly options: AppCLIEntryOptions) {}
/**
@@ -293,15 +204,6 @@ export class AppCLIEntry {
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).
const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
@@ -333,12 +235,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 +269,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. */

View File

@@ -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/)
})
})