Merge remote-tracking branch 'origin/master' into worktree/attachment-alignment-2

This commit is contained in:
creatixchu
2026-08-12 15:19:32 +08:00
133 changed files with 3306 additions and 552 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/boot/cmdline/README.md
README.md: 2e8e58b23785fa78bd2663a459817669309a81be
README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd
README.md: 33125014539e801dbd2952a3b4513cafc80bdcee
README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559

View File

@@ -15,15 +15,16 @@ An embedding host with no command line provides an empty list; that is the hones
## Ordinary providers and injected config
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service:
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service:
```ts ignore
export const name = 'web-startup'
export const inject = ['cmdlineArgs']
export function apply(ctx: Context): void {
const values = parseCmdline(ctx, webCommand(), planWebStartup)
if (values !== undefined) ctx.provide('webStartup', values)
const program = webCommand()
program.action(() => ctx.provide('webStartup', webValuesFrom(program)))
parseCmdline(ctx, program)
}
```
@@ -45,7 +46,7 @@ Every row configured from those values uses ordinary service injection and direc
port: !!js ctx.webStartup.port ?? 3080
```
`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate.
`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate.
### How injection orders config

View File

@@ -15,15 +15,16 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属
## 普通提供方与注入配置
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander返回值与服务都归调用方持有:
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander校验与发布的服务都归 program 自己的 action 持有:
```ts ignore
export const name = 'web-startup'
export const inject = ['cmdlineArgs']
export function apply(ctx: Context): void {
const values = parseCmdline(ctx, webCommand(), planWebStartup)
if (values !== undefined) ctx.provide('webStartup', values)
const program = webCommand()
program.action(() => ctx.provide('webStartup', webValuesFrom(program)))
parseCmdline(ctx, program)
}
```
@@ -45,7 +46,7 @@ export function apply(ctx: Context): void {
port: !!js ctx.webStartup.port ?? 3080
```
`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。
`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program把每个命令的退出与输出都接到启动器上commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本请求退出;提供方什么也不发布,因此依赖行不会激活。
### 注入如何排列配置求值

View File

@@ -8,7 +8,8 @@
* text, and its parse errors instead of the launcher knowing them.
*
* Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A
* provider may publish the parsed values as its own service, and ordinary rows
* provider may publish the parsed values as its own service from its program's
* commander action, and ordinary rows
* can inject that service and read it from lazily resolved config —
* `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written
* beside it. No row has launcher-level command-line status.
@@ -76,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w
stderr: process.stderr,
}
/**
* Resolve parsed arguments into an app-owned value. Call
* `program.error(...)` to reject the invocation with a usage message instead
* of throwing.
* @param program - the parsed commander program.
* @param ctx - the plugin context that received the command line.
* @returns the value an ordinary provider plugin may publish.
*/
export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T
/**
* Parse the launcher's immutable argument snapshot with an app's commander
* program. The caller decides whether and how to publish the returned value;
* this helper has no Loader-row or service ownership semantics.
* program. Commander runs the program's own synchronous action handler on a
* successful parse; app code there publishes its service and rejects an
* invalid invocation with `program.error(...)`. This helper has no Loader-row
* or service ownership semantics.
*
* Help, version, and rejected arguments are terminal for the process: commander
* writes the text, the helper requests `ctx.appExit`, and it returns
* `undefined` so the caller publishes nothing.
* Help, version, and rejected arguments — from the grammar or from an action
* — are terminal for the process: commander writes the text and the helper
* requests `ctx.appExit`. The action never runs on help, version, or a
* grammar rejection; an action must reject before it publishes, because
* statements before its `program.error(...)` have already run.
* @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
* @param program - the app's commander program, with its flags and description already declared.
* @param plan - this invocation's resolved value; omitted returns an empty object.
* @returns the resolved value, or `undefined` when the app asked to exit.
* @throws when the launcher did not provide the command line and exit request.
* @param program - the app's commander program, with its flags, description,
* actions, and any subcommands already declared.
* @throws when the launcher did not provide the command line and exit request,
* or when no command in the program declares an action.
*/
export function parseCmdline<T>(
ctx: Context,
program: Command,
plan: CmdlinePlan<T> = (() => ({}) as T),
): T | undefined {
export function parseCmdline(ctx: Context, program: Command): void {
// Read through the global service store, not the property proxy: appExit is
// an optional host value and the plugin only needs to inject cmdlineArgs.
const args = ctx.get('cmdlineArgs')
@@ -112,23 +103,54 @@ export function parseCmdline<T>(
if (args === undefined || exit === undefined) {
throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`)
}
program
if (!hasAction(program)) {
throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`)
}
configureExitAndOutput(program)
try {
program.parse(args.get(), { from: 'user' })
} catch (error) {
// exitOverride turns help, version, a parse error, and the action's own
// program.error() into a CommanderError; commander has already written the
// text through the output configured above.
if (!isCommanderError(error)) throw error
exit(error.exitCode)
}
}
/**
* Whether any command in the tree declares an action handler.
*
* The `Command` type cannot express the action precondition, so the handler is
* read structurally (as {@link isCommanderError} reads commander's control-flow
* errors): without this guard, a program that forgot its action would parse
* successfully, publish nothing, and surface only as dependent rows pending on
* the absent service.
* @param command - the command whose tree is inspected.
* @returns true when the command or any registered subcommand has an action.
*/
function hasAction(command: Command): boolean {
if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true
return command.commands.some(hasAction)
}
/**
* Route every command's exit and output through the launcher adapter.
*
* Commander copies `exitOverride` and output configuration into a subcommand
* only at registration, so a root-only override would let an
* already-registered subcommand's rejection write to the process streams and
* call `process.exit` directly, bypassing `ctx.appExit`.
* @param command - the root of the command tree to configure.
*/
function configureExitAndOutput(command: Command): void {
command
.exitOverride()
.configureOutput({
writeOut: text => void internals.stdout.write(text),
writeErr: text => void internals.stderr.write(text),
})
try {
program.parse(args.get(), { from: 'user' })
return plan(program, ctx)
} catch (error) {
// exitOverride turns help, version, a parse error, and a plan's own
// program.error() into a CommanderError; commander has already written the
// text through the output configured above.
if (!isCommanderError(error)) throw error
exit(error.exitCode)
return undefined
}
for (const child of command.commands) configureExitAndOutput(child)
}
/**

View File

@@ -14,7 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { afterEach, describe, expect, it } from 'vitest'
import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts'
import { internals, parseCmdline, provideCmdline } from '../src/index.ts'
/** Every value one boot of the fixture tree observed. */
interface Observed {
@@ -43,8 +43,8 @@ function demoCommand(): Command {
return new Command().name('demo').exitOverride().option('--port <port>', 'listen port')
}
/** The fixture app's plan: the resolved values its rows read. */
const demoPlan: CmdlinePlan<{ port?: number }> = (program) => {
/** The fixture app's action body: the resolved values its rows read. */
const resolveDemo = (program: Command): { port?: number } => {
const port = program.opts<{ port?: string }>().port
if (port === undefined) return {}
if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`)
@@ -58,12 +58,12 @@ const expression = (source: string): unknown => ({ __jsExpr: source })
* Mount a two-row composition the way a profile boot does: both rows at once,
* with Loader ordering config resolution from their injections.
* @param args - the invocation's inner arguments.
* @param plan - the app's plan; defaults to the fixture's own.
* @param resolve - the app's action body; defaults to the fixture's own.
* @returns the booted fixture.
*/
async function bootFixture(
args: string[],
plan: CmdlinePlan = demoPlan,
resolve: (program: Command) => unknown = resolveDemo,
options: { objectInject?: boolean; withoutProvider?: boolean } = {},
): Promise<Fixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
@@ -88,8 +88,9 @@ export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) }
const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void }
globals.__observed = observed
globals.__provideDemoArgs = (ctx: Context) => {
const values = parseCmdline(ctx, demoCommand(), plan)
if (values !== undefined) ctx.provide('demoStartup', values)
const program = demoCommand()
program.action(() => { ctx.provide('demoStartup', resolve(program)) })
parseCmdline(ctx, program)
}
// The composition, exactly as a profile delivers one: include patches whose
@@ -133,7 +134,7 @@ describe('parseCmdline', () => {
})
it('recognizes the Loader object form of a provider-service injection', async () => {
const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true })
const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true })
expect(observed.started).toEqual({ port: 8080 })
})
@@ -144,31 +145,35 @@ describe('parseCmdline', () => {
expect(observed.exits).toEqual([0])
})
it('rejects the invocation from the plan without starting the app', async () => {
it('rejects the invocation from the action without starting the app', async () => {
const { observed } = await bootFixture(['--port', 'abc'])
expect(observed.out).toContain('--port must be a number')
expect(observed.started).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('rethrows a plan failure that is not commander asking to exit', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
const plan: CmdlinePlan = () => { throw new Error('plan exploded') }
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded')
it('rethrows an action failure that is not commander asking to exit', async () => {
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
const program = demoCommand().action(() => { throw new Error('action exploded') })
expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded')
})
it('rethrows a thrown value that is not an object at all', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
const plan: CmdlinePlan = () => {
const thrown: unknown = 'plan threw a string'
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
const program = demoCommand().action(() => {
const thrown: unknown = 'action threw a string'
throw thrown
}
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string')
})
expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string')
})
it('returns values without inspecting Loader rows or owning a service', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
expect(parseCmdline(ctx, demoCommand())).toEqual({})
it('runs the action without inspecting Loader rows or owning a service', async () => {
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
let values: unknown
const program = demoCommand()
program.action(() => { values = resolveDemo(program) })
parseCmdline(ctx, program)
expect(values).toEqual({})
expect(ctx.get('demoStartup')).toBeUndefined()
})
})
@@ -182,6 +187,28 @@ describe('provideCmdline', () => {
expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
})
it('refuses at load a program in which no command declares an action', async () => {
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
expect(() => { parseCmdline(ctx, demoCommand()) })
.toThrow('no command in the program declares an action')
})
it('routes a pre-registered subcommand rejection through the launcher exit request', () => {
const ctx = new Context()
const exits: number[] = []
let err = ''
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) })
// The root declares no action of its own: the tree-wide guard accepts the
// subcommand's, and the subcommand inherits the exit and output routing.
const program = new Command().name('demo')
const child = program.command('serve')
child.action(() => { child.error('error: serve rejected') })
parseCmdline(ctx, program)
expect(err).toContain('serve rejected')
expect(exits).toEqual([1])
})
it('fails loud when a parser runs without the launcher values', () => {
const ctx = new Context()
expect(() => { parseCmdline(ctx, demoCommand()) })
@@ -191,8 +218,15 @@ describe('provideCmdline', () => {
it('lets multiple parsers read the same immutable snapshot', () => {
const ctx = new Context()
provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} })
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
const parseOnce = (): unknown => {
let values: unknown
const program = demoCommand()
program.action(() => { values = resolveDemo(program) })
parseCmdline(ctx, program)
return values
}
expect(parseOnce()).toEqual({ port: 8080 })
expect(parseOnce()).toEqual({ port: 8080 })
expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
})
})

View File

@@ -41,22 +41,17 @@ Examples:
}
/**
* Turn the parsed command line into the runner's task.
* @param program - the parsed headless command.
* @returns the runner's service value.
*/
function planHeadlessStartup(program: Command): HeadlessStartupValues {
const task = program.args.join(' ')
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
return { task }
}
/**
* Parse and provide the one-shot task as an ordinary Cordis service.
* Parse and provide the one-shot task as an ordinary Cordis service. The
* command's action publishes the task; a missing or whitespace-only task is a
* usage error, so on rejection (and on `--help`) nothing is provided.
* @param ctx - plugin context carrying the command line.
* @returns nothing once the task is provided, or when the command requested exit.
*/
export function apply(ctx: Context): void {
const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup)
if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values)
const program = headlessCommand()
program.action(() => {
const task = program.args.join(' ')
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues)
})
parseCmdline(ctx, program)
}

View File

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

View File

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

View File

@@ -57,28 +57,24 @@ Examples:
}
/**
* Turn the parsed flags into the value injected rows read.
* @param program - the parsed web command.
* @returns this invocation's immutable Web options.
*/
function planWebStartup(program: Command): WebStartupValues {
const options = program.opts<WebOptions>()
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
return {
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
trustedHosts: options.trustedHost ?? [],
}
}
/**
* Parse and provide the Web invocation as an ordinary Cordis service.
* Parse and provide the Web invocation as an ordinary Cordis service. The
* command's action publishes the flags this invocation named; a non-numeric
* `--port` is a usage error, so on rejection (and on `--help`) nothing is
* provided.
* @param ctx - plugin context carrying the command line.
* @returns nothing once values are provided, or when the command requested exit.
*/
export function apply(ctx: Context): void {
const values = parseCmdline(ctx, webCommand(), planWebStartup)
if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values)
const program = webCommand()
program.action(() => {
const options = program.opts<WebOptions>()
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
ctx.provide(WEB_STARTUP_SERVICE, {
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
trustedHosts: options.trustedHost ?? [],
} satisfies WebStartupValues)
})
parseCmdline(ctx, program)
}

View File

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

View File

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

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: f6604f822412e9eb4574696f5b99e73fb7bd98ff
README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de
README.md: a8d030b7676e87709fb36b87a6599decc43e0b4b
README.zh.md: 63fb1b486acc2bca34792f485ffd89fb32749e43

View File

@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
The DeepSeek step projects first-run readiness from that same joined snapshot after earlier onboarding pages complete. The step exists to leave the user with a model to talk to, so ANY provider they can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference at all and therefore authenticates natively. Only a user with none of those is asked about DeepSeek, the one route the prompt can offer a key field for. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.

View File

@@ -4,9 +4,9 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile因此能保留提供方原生认证例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它协议没有这样的兜底。内置目录路由两个都不给它的名称由目录条目兜底它的每个模型各自带着自己的协议路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中它是按模型的能力而同一提供方下各模型接受的档位并不一致因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile因此能保留提供方原生认证例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它协议没有这样的兜底。内置目录路由两个都不给它的名称由目录条目兜底它的每个模型各自带着自己的协议路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中它是按模型的能力而同一提供方下各模型接受的档位并不一致因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出首次运行就绪状态。该步骤的存在是为了让用户手上有一个可对话的模型,因此只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名任何引用、因而走原生认证的路由。只有二者皆无的用户才会被问到 DeepSeek即这条提示唯一能为其提供密钥输入框的路由。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它只修改自己看得见的字段而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定trim 之后必须非空,且每个字符都是可打印 ASCII`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm``normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated``credentials/updated``llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。

View File

@@ -1,7 +1,9 @@
/**
* Official-DeepSeek first-run step. Readiness comes from the same
* provider/settings/credential join as the Models page; the prompt only
* routes the user to that page's single credential editor.
* provider/settings/credential join as the Models page: any provider the user
* can already talk to ends the step, and only a user with none is offered the
* official DeepSeek route. The prompt itself only routes to that page's single
* credential editor.
*/
import { useEffect, useRef } from 'react'
@@ -10,7 +12,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
import { onboardingReadiness } from './store.ts'
import type { en } from './locales.ts'
import styles from './DeepSeekOnboardingDialog.module.css'
@@ -34,15 +36,15 @@ function assertNever(_value: never): never {
}
/**
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
* Prompt a first-run user to open Models while no provider can serve requests
* and the official adapter exists with an unconfigured effective credential.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the onboarding page or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { complete, openSection, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const readiness = deepSeekReadiness(state)
const readiness = onboardingReadiness(state)
const titleRef = useRef<HTMLHeadingElement | null>(null)
useEffect(() => {
@@ -52,7 +54,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
useEffect(() => {
if (
readiness.kind === 'adapter-absent'
|| readiness.kind === 'configured'
|| readiness.kind === 'provider-ready'
|| readiness.kind === 'unavailable'
) complete()
}, [complete, readiness.kind])
@@ -72,7 +74,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
case 'configured':
case 'provider-ready':
case 'unavailable':
return null
case 'credential-missing':

View File

@@ -3,11 +3,13 @@
* directory, settings namespaces, and credential states, with one editor
* card at a time. Rows expose only confirmed API-key state through accessible
* solid configured or missing dots. A whole-section provider without a
* configured key (the unconfigured DeepSeek posture) renders as its open setup
* card instead of a row; the add flow is a card carrying the dormant-provider
* select. Every mutation writes through the wire, while a provider removal first requires
* confirmation; the page re-renders from pushed invalidations or the
* post-apply reload.
* configured key renders as its open setup card instead of a row, but only in
* the first-run posture — no provider on the page can serve requests yet — and
* only until the user closes that card; the add flow is a card carrying the
* dormant-provider select. Each card kind owns its own open state, so closing
* one never discards a draft in another. Every mutation writes through the
* wire, while a provider removal first requires confirmation; the page
* re-renders from pushed invalidations or the post-apply reload.
*/
import { useState } from 'react'
@@ -16,7 +18,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
@@ -116,11 +118,15 @@ export async function removeProviderProfile(
/**
* Whether a whole-section provider still needs its first key: an unconfigured
* credential opens the setup card instead of showing a row.
* credential opens the setup card instead of showing a row. This is the
* first-run posture alone — a user who can already reach some provider gets an
* ordinary row with the missing-key dot, since nothing here is blocking them.
* @param row - the joined provider row.
* @param anyUsable - whether any joined row can already serve requests.
* @returns whether to render the setup card.
*/
export function needsSetup(row: ProviderRow): boolean {
export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean {
if (anyUsable) return false
if (row.entry.settingsPath.length > 0) return false
return row.credential?.configured !== true
}
@@ -178,17 +184,32 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
const [declaring, setDeclaring] = useState(false)
const [dismissedSetup, setDismissedSetup] = useState<ReadonlySet<string>>(() => new Set())
const announceSaved = (target: ProviderIdentity): void => {
// Announced only once the refreshed directory is in the snapshot the
// notice reads its name from: an apply can rename the route, and the
// target captured when the card opened still carries the old name.
void controller.load().then(() => { setSavedTarget(target) })
}
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
if (changed) {
// Announced only once the refreshed directory is in the snapshot the
// notice reads its name from: an apply can rename the route, and the
// target captured when the card opened still carries the old name.
void controller.load().then(() => { setSavedTarget(target) })
}
if (changed) announceSaved(target)
}
/**
* Close a setup card, which owns none of the state above: the row-editor,
* add, and declare cards each own one of those, so clearing them here would
* discard a draft the user opened beside this card. Dismissal is this card's
* own — the provider falls back to an ordinary row for the rest of the
* session, and reopens through Edit.
*/
const closeSetup = (changed: boolean, target: ProviderIdentity): void => {
setDismissedSetup(previous => new Set([...previous, target.provider]))
if (changed) announceSaved(target)
}
const closeDelete = (): void => {
@@ -238,6 +259,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
? savedTarget
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
// One fact decides both first-run postures on this page and the onboarding
// step: whether the user already has a provider to talk to.
const anyUsable = state.rows.some(providerUsable)
const configured = state.rows.filter(row => row.configured)
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
const addTarget = adding ? editing : undefined
@@ -265,9 +289,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const namespace = state.namespaces.get(target.settingsNs)
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
if (namespace === undefined) return null
if (needsSetup(row)) {
if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) {
// First-run posture: the provider exists but has no key — the
// setup card IS its presence on the page.
// setup card IS its presence on the page, until the user closes it.
return (
<li key={row.entry.provider} className={styles['setupCard']}>
{renderProviderEditor({
@@ -276,7 +300,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
api,
t,
readOnly: !state.writable,
onClose: (changed) => { closeEditor(changed, target) },
onClose: (changed) => { closeSetup(changed, target) },
})}
</li>
)

View File

@@ -189,32 +189,49 @@ export class ModelsSettingsStore {
}
}
/** DeepSeek onboarding readiness derived only from the shared Models join. */
export type DeepSeekReadiness =
/**
* Whether a joined row can serve model requests as it stands: the route is
* registered with the adapter registry, and whatever credential its resolved
* profile names is stored. A profile naming no reference authenticates through
* the provider's own path (the Bedrock chain, Vertex ADC, a gateway that needs
* nothing), as does a live route with no settings address at all, so neither
* owes this page a key.
* @param row - one joined provider row.
* @returns whether the user already has this provider to talk to.
*/
export function providerUsable(row: ProviderRow): boolean {
if (!row.entry.active) return false
if (row.apiKeyEnv === undefined) return true
return row.credential?.configured === true
}
/** First-run onboarding readiness derived only from the shared Models join. */
export type OnboardingReadiness =
| { kind: 'loading' }
| { kind: 'adapter-absent' }
| { kind: 'configured' }
| { kind: 'provider-ready' }
| { kind: 'credential-missing' }
| {
kind: 'unavailable'
reason:
| 'load-failed'
| 'provider-inactive'
| 'settings-unavailable'
| 'credential-ref-unavailable'
| 'credentials-unavailable'
| 'settings-read-only'
| 'credential-read-only'
}
/**
* Project official-DeepSeek readiness from the provider/settings/credential
* join used by the Models page. A missing official configurable-provider
* Project first-run readiness from the provider/settings/credential join used
* by the Models page. The step exists to leave the user with a model to talk
* to, so ANY usable provider ends it; only when none exists does the official
* DeepSeek route — the one route the prompt can offer a key field for — decide
* whether prompting can help. A missing official configurable-provider
* declaration means the adapter is not repairable by navigating to Models.
* @param state - current shared Models join snapshot.
* @returns the onboarding state without reading a parallel fact source.
*/
export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness {
export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadiness {
if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) {
return { kind: 'loading' }
}
@@ -224,6 +241,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
reason: 'load-failed',
}
}
if (state.rows.some(providerUsable)) return { kind: 'provider-ready' }
const row = state.rows.find(candidate =>
candidate.entry.provider === 'deepseek-official'
&& candidate.entry.settingsNs === 'llm-deepseek'
@@ -235,33 +253,14 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
reason: 'provider-inactive',
}
}
if (!row.configured) {
return {
kind: 'unavailable',
reason: 'settings-unavailable',
}
}
if (row.apiKeyEnv === undefined) {
return {
kind: 'unavailable',
reason: 'credential-ref-unavailable',
}
}
if (state.credentialError !== null) {
// Past the usable gate an active route names a reference it has no stored
// credential for, so the remaining questions are all about that credential.
if (state.credentialError !== null || row.credential === undefined) {
return {
kind: 'unavailable',
reason: 'credentials-unavailable',
}
}
if (row.credential === undefined) {
return {
kind: 'unavailable',
reason: 'credentials-unavailable',
}
}
if (row.credential.configured) {
return { kind: 'configured' }
}
if (!state.writable) {
return {
kind: 'unavailable',

View File

@@ -23,6 +23,8 @@ afterEach(cleanup)
const t: ModelsSectionInjected['t'] = key => en[key]
const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
const DEEPSEEK_TARGET = { provider: 'deepseek-official', displayName: 'DeepSeek' }
const deepSeekCopy = (template: string): string => providerCopy(template, DEEPSEEK_TARGET)
/** Open one row's capacity disclosure (1-based, as the labels read). */
function expandRow(position: number): void {
@@ -181,8 +183,8 @@ function scriptedFace(overrides: {
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
const { face, update, replace, mutate, set, unset } = scripted
const controller = new ModelsSettingsStore(face as unknown as WireFace)
await controller.load()
const injected: ModelsSectionInjected = {
@@ -195,6 +197,34 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
return { view, face, update, replace, mutate, set, unset, controller }
}
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
return mountFace(scriptedFace(overrides))
}
/**
* Mount for a user who cannot reach any provider yet: no credential is stored
* anywhere, so the whole-section DeepSeek route owns the first-run setup card.
*/
async function mountFirstRun(overrides: Parameters<typeof scriptedFace>[0] = {}) {
const scripted = scriptedFace(overrides)
scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) =>
Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
})))
return mountFace(scripted)
}
/**
* Mount and open the DeepSeek editor. The shared fixture already has a usable
* openai route, so DeepSeek is an ordinary row whose card opens through Edit
* rather than by itself.
*/
async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] = {}) {
const mounted = await mountSection(overrides)
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
return mounted
}
describe('ModelsSection', () => {
it('renders nothing before the slot injects its dependencies', () => {
const uninjected = {} as ModelsSectionProps
@@ -202,20 +232,32 @@ describe('ModelsSection', () => {
expect(document.body.textContent).toBe('')
})
it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
await mountSection()
// DeepSeek has no configured credential and no stored apiKey → setup card.
it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
await mountFirstRun()
// Nothing is reachable yet, and DeepSeek has no configured credential and
// no stored apiKey → setup card.
expect(screen.getByText('DeepSeek')).toBeTruthy()
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
expect(screen.getByText('openai')).toBeTruthy()
expect(screen.queryByText('Active')).toBeNull()
expect(screen.queryByText('Inactive')).toBeNull()
expect(screen.getByText(en.add)).toBeTruthy()
})
it('leaves the unkeyed provider a plain row once another provider is usable', async () => {
await mountSection()
// openai's key is stored, so the user is not blocked and nothing on the
// page opens itself over them.
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
const configured = screen.getByRole('img', { name: en.credentialConfigured })
expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
expect(configured.className).toContain('credentialDotConfigured')
expect(configured.closest('li')?.textContent).toContain('openai')
expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
expect(screen.getByText(en.add)).toBeTruthy()
const missing = screen.getByRole('img', { name: en.credentialMissing })
expect(missing.closest('li')?.textContent).toContain('DeepSeek')
// The card is still one click away.
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
})
it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
@@ -241,7 +283,7 @@ describe('ModelsSection', () => {
})
it('turns the setup card into a row once the credential reports configured', async () => {
const { face } = await mountSection()
const { face } = await mountFirstRun()
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
})))
@@ -259,7 +301,7 @@ describe('ModelsSection', () => {
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
})
it('decides setup need from the joined credential state', () => {
it('decides setup need from the joined credential state and the first-run posture', () => {
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
const row = (credential: ProviderRow['credential']): ProviderRow => ({
entry,
@@ -268,10 +310,13 @@ describe('ModelsSection', () => {
apiKeyEnv: 'X',
credential,
})
expect(needsSetup(row(undefined))).toBe(true)
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
expect(needsSetup(row(undefined), false)).toBe(true)
expect(needsSetup(row({ configured: true, writable: true }), false)).toBe(false)
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
expect(needsSetup(nested)).toBe(false)
expect(needsSetup(nested, false)).toBe(false)
// A user who can already reach some provider is not in the first-run
// posture, so nothing on the page opens itself.
expect(needsSetup(row(undefined), true)).toBe(false)
})
it('derives conventional credential references from route ids', () => {
@@ -296,7 +341,7 @@ describe('ModelsSection', () => {
})
it('stores a typed key write-only from the setup card without touching settings', async () => {
const { set, update, face } = await mountSection()
const { set, update, face } = await mountFirstRun()
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(key, { target: { value: ' sk-live ' } })
fireEvent.click(screen.getByText(en.apply))
@@ -311,7 +356,7 @@ describe('ModelsSection', () => {
})
it('applies customized deepseek fields as path ops', async () => {
const { mutate } = await mountSection({
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
@@ -332,7 +377,7 @@ describe('ModelsSection', () => {
})
it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
const { mutate } = await mountSection({
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
@@ -366,7 +411,7 @@ describe('ModelsSection', () => {
})
it('rejects duplicate DeepSeek model ids before writing', async () => {
const { mutate } = await mountSection()
const { mutate } = await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
fireEvent.click(screen.getByText(en.addModel))
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
@@ -436,7 +481,7 @@ describe('ModelsSection', () => {
})
it('accepts a suffixed context window and stores the plain count', async () => {
const { mutate } = await mountSection({
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
@@ -476,7 +521,7 @@ describe('ModelsSection', () => {
})
it('keeps unreadable context-window text on screen and refuses the write', async () => {
const { mutate } = await mountSection()
const { mutate } = await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
@@ -539,7 +584,7 @@ describe('ModelsSection', () => {
// The regression: one active buffer meant editing a second row displaced
// the first, which then fell back to rendering its stored NaN as `NaN` —
// losing the text the user was told they could still correct.
await mountSection()
await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
@@ -553,7 +598,7 @@ describe('ModelsSection', () => {
})
it('re-keys the typed text around a removed row', async () => {
await mountSection()
await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
const removeRow = (at: number): void => {
@@ -587,7 +632,7 @@ describe('ModelsSection', () => {
// The regression: reset removed the override but left the buffer, so an
// inherited row displayed text no settings layer stores — and because an
// unreadable buffer never settles, it stayed there indefinitely.
const { mutate } = await mountSection({
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
@@ -605,12 +650,12 @@ describe('ModelsSection', () => {
// Reset put the draft back where it started, so Apply writes nothing at
// all rather than persisting whatever the stale text had parsed to.
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
expect(mutate).not.toHaveBeenCalled()
})
it('edits an output cap per model and carries its text across a removal', async () => {
const { mutate } = await mountSection({
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
@@ -644,7 +689,7 @@ describe('ModelsSection', () => {
})
it('settles a pasted id and refuses whitespace that would never match', async () => {
await mountSection()
await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
@@ -681,7 +726,7 @@ describe('ModelsSection', () => {
})
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
const { mutate } = await mountSection({
const { mutate } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
@@ -715,7 +760,7 @@ describe('ModelsSection', () => {
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
// A whole-section replace would clobber sibling overrides to clear one field.
const { replace, update, mutate } = await mountSection()
const { replace, update, mutate } = await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
expect(url.value).toBe('https://base')
@@ -762,7 +807,7 @@ describe('ModelsSection', () => {
})
it('rejects an invalid draft before writing', async () => {
const { update } = await mountSection()
const { update } = await mountDeepSeekCard()
fireEvent.click(screen.getByText(en.customized))
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
fireEvent.click(screen.getByText(en.apply))
@@ -772,19 +817,17 @@ describe('ModelsSection', () => {
it('edits a pi-ai profile with the curated fields only', async () => {
const { mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
// The configured credential shows as the stored placeholder.
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
const editorKey = keys[keys.length - 1] as HTMLInputElement
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
// pi-ai carries Base URL too: the stored override shows as the value and
// the effective profile endpoint as its placeholder source.
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
expect(urls).toHaveLength(2)
expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
fireEvent.click(screen.getByText(en.customized))
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
expect(url.value).toBe('https://proxy')
fireEvent.change(url, { target: { value: 'https://proxy/v2' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the edited field travels: apiKeyEnv and headers were already stored
// with these values, so no op restates them.
@@ -803,14 +846,12 @@ describe('ModelsSection', () => {
expect(pick.value).toBe('anthropic')
// A dormant profile has no endpoint anywhere: the pi-ai placeholder
// falls back to the provider-default wording.
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
const addKey = keys[keys.length - 1] as HTMLInputElement
fireEvent.click(screen.getByText(en.customized))
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder).toBe(en.baseUrlDefault)
const addKey = screen.getByLabelText<HTMLInputElement>(en.keyInput)
expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
@@ -824,7 +865,7 @@ describe('ModelsSection', () => {
const { mutate, set } = await mountSection()
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
@@ -855,9 +896,8 @@ describe('ModelsSection', () => {
const { face, controller } = await mountSection({ mutate, set })
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-ant' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText('credential store unavailable')
expect(mutate).toHaveBeenCalledOnce()
face.settings.describe.mockResolvedValue(ok({
@@ -867,7 +907,7 @@ describe('ModelsSection', () => {
}))
await act(async () => { await controller.load() })
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
expect(mutate).toHaveBeenCalledOnce()
expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
@@ -883,10 +923,9 @@ describe('ModelsSection', () => {
await waitFor(() => {
expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
})
// The hint-only card cannot apply anything.
const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
// The hint-only card cannot apply anything, and offers no key field.
expect(screen.getByText<HTMLButtonElement>(en.apply).disabled).toBe(true)
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
})
it('surfaces a rejected settings write and never stores the key after it', async () => {
@@ -895,9 +934,8 @@ describe('ModelsSection', () => {
})
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-x' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(/unknown pi-ai provider/)
expect(set).not.toHaveBeenCalled()
})
@@ -930,7 +968,7 @@ describe('ModelsSection', () => {
it('tells the user to reopen when another writer moved the namespace first', async () => {
// The stale-draft overwrite: two tabs open the same card, the other saves,
// and this one must be refused rather than replay its opening snapshot.
const { set } = await mountSection({
const { set } = await mountDeepSeekCard({
mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
})
fireEvent.click(screen.getByText(en.customized))
@@ -944,7 +982,7 @@ describe('ModelsSection', () => {
// A transport failure (disconnect, or the 403 a non-loopback browser now
// gets on the whole configuration plane) rejects rather than returning a
// failed envelope: without a catch the card would stay busy forever.
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
fireEvent.click(screen.getByText(en.customized))
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
fireEvent.click(screen.getByText(en.apply))
@@ -954,7 +992,7 @@ describe('ModelsSection', () => {
})
it('surfaces a shadowed credential write on the card', async () => {
await mountSection({
await mountFirstRun({
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
})
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
@@ -971,9 +1009,8 @@ describe('ModelsSection', () => {
configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
}])),
})))
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
const editorKey = keys[keys.length - 1] as HTMLInputElement
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
expect(editorKey.disabled).toBe(true)
})
@@ -981,12 +1018,11 @@ describe('ModelsSection', () => {
it('keeps a failed credential describe silent and the input usable', async () => {
const { face, set } = await mountSection()
face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
const editorKey = keys[keys.length - 1] as HTMLInputElement
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
})
@@ -1085,15 +1121,15 @@ describe('ModelsSection', () => {
it('toggles the row editor closed on a second edit click and on cancel', async () => {
const { update } = await mountSection()
const edit = screen.getAllByText(en.edit)[0] as HTMLElement
const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) })
fireEvent.click(edit)
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
fireEvent.click(edit)
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
fireEvent.click(edit)
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
fireEvent.click(screen.getByText(en.cancel))
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
expect(update).not.toHaveBeenCalled()
})
@@ -1101,11 +1137,34 @@ describe('ModelsSection', () => {
await mountSection()
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
fireEvent.click(screen.getByText(en.cancel))
await screen.findByText(en.add)
expect(screen.queryByLabelText(en.provider)).toBeNull()
})
it('collapses the setup card on cancel without disturbing another open card', async () => {
// The regression: the setup card shared the row/add/declare close handler,
// so cancelling it discarded the add card's draft while staying open itself.
await mountFirstRun()
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2)
// The setup card is the first one on the page, above the add block.
fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement)
// The add card kept its draft…
expect(screen.getByLabelText(en.provider)).toBeTruthy()
// …and DeepSeek collapsed to an ordinary row carrying the missing-key dot.
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
expect(screen.getAllByRole('img', { name: en.credentialMissing })
.some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true)
// Its card reopens through Edit, which closes the add card as any row does.
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
expect(screen.queryByLabelText(en.provider)).toBeNull()
})
it('loads on first render of an idle controller', async () => {
const { face } = scriptedFace()
const controller = new ModelsSettingsStore(face as unknown as WireFace)

View File

@@ -1,8 +1,8 @@
/** Pure official-DeepSeek readiness projection over the shared Models join. */
/** Pure first-run readiness projection over the shared Models join. */
import { describe, expect, it } from 'vitest'
import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client'
import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
import { deepSeekReadiness } from '../src/client/store.ts'
import { onboardingReadiness, providerUsable } from '../src/client/store.ts'
const missingCredential: CredentialView = { configured: false, writable: true }
@@ -23,6 +23,24 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
}
}
/** A second provider the user configured themselves. */
function otherRow(overrides: Partial<ProviderRow> = {}): ProviderRow {
return {
entry: {
provider: 'hfai',
displayName: 'HFAI',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'hfai'],
active: true,
},
configured: true,
removable: true,
apiKeyEnv: 'HFAI_API_KEY',
credential: { configured: true, source: 'file', writable: true },
...overrides,
}
}
function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
return {
status: 'ready',
@@ -35,12 +53,25 @@ function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsStat
}
}
describe('deepSeekReadiness', () => {
describe('providerUsable', () => {
it('requires a registered route and a stored key for every named reference', () => {
expect(providerUsable(otherRow())).toBe(true)
expect(providerUsable(otherRow({ entry: { ...otherRow().entry, active: false } }))).toBe(false)
expect(providerUsable(otherRow({ credential: missingCredential }))).toBe(false)
expect(providerUsable(otherRow({ credential: undefined }))).toBe(false)
})
it('treats a reference-free registered route as provider-native authentication', () => {
expect(providerUsable(otherRow({ apiKeyEnv: undefined, credential: undefined }))).toBe(true)
})
})
describe('onboardingReadiness', () => {
it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => {
expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
expect(deepSeekReadiness(state({
expect(onboardingReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
expect(onboardingReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
expect(onboardingReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
expect(onboardingReadiness(state({
rows: [row({
entry: {
...row().entry,
@@ -51,45 +82,47 @@ describe('deepSeekReadiness', () => {
})
it('reports a missing writable effective credential', () => {
expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
expect(onboardingReadiness(state())).toEqual({ kind: 'credential-missing' })
})
it('ends onboarding once any other registered provider can serve requests', () => {
expect(onboardingReadiness(state({ rows: [row(), otherRow()] }))).toEqual({ kind: 'provider-ready' })
// A provider the user cannot reach yet leaves the prompt in place.
expect(onboardingReadiness(state({
rows: [row(), otherRow({ credential: missingCredential })],
}))).toEqual({ kind: 'credential-missing' })
})
it('accepts file and process-environment credentials without prompting', () => {
expect(deepSeekReadiness(state({
expect(onboardingReadiness(state({
rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
}))).toEqual({ kind: 'configured' })
expect(deepSeekReadiness(state({
}))).toEqual({ kind: 'provider-ready' })
expect(onboardingReadiness(state({
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
}))).toEqual({ kind: 'configured' })
}))).toEqual({ kind: 'provider-ready' })
})
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
it('turns missing capabilities into diagnostics that never block the product', () => {
expect(onboardingReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
kind: 'unavailable',
reason: 'load-failed',
})
expect(deepSeekReadiness(state({
expect(onboardingReadiness(state({
rows: [row({ entry: { ...row().entry, active: false } })],
}))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
expect(deepSeekReadiness(state({
rows: [row({ configured: false })],
}))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
expect(deepSeekReadiness(state({
rows: [row({ apiKeyEnv: undefined })],
}))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
expect(deepSeekReadiness(state({
expect(onboardingReadiness(state({
credentialError: 'credentials service is absent',
}))).toEqual({
kind: 'unavailable',
reason: 'credentials-unavailable',
})
expect(deepSeekReadiness(state({
expect(onboardingReadiness(state({
rows: [row({ credential: undefined })],
}))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
expect(deepSeekReadiness(state({
expect(onboardingReadiness(state({
rows: [row({ credential: { configured: false, writable: false } })],
}))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
expect(deepSeekReadiness(state({ writable: false }))).toEqual({
expect(onboardingReadiness(state({ writable: false }))).toEqual({
kind: 'unavailable',
reason: 'settings-read-only',
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,18 @@ export const IconSearchOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_globe_outline_14 — meridian globe (harness-only figma extract). */
export const IconGlobeOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.00018 0.353516C10.6708 0.353535 13.6468 3.32958 13.6469 7.00018C13.6468 10.6708 10.6708 13.6468 7.00018 13.6469C3.32957 13.6468 0.353535 10.6708 0.353516 7.00018C0.353535 3.32957 3.32957 0.353531 7.00018 0.353516ZM5.44643 7.59661C5.49463 8.97506 5.70762 10.191 6.02136 11.0793C6.20141 11.5891 6.40328 11.9585 6.59898 12.1889C6.79501 12.4196 6.93213 12.454 7.00018 12.454C7.06822 12.454 7.20533 12.4197 7.40138 12.1889C7.59708 11.9585 7.79895 11.589 7.979 11.0793C8.29274 10.191 8.50574 8.97506 8.55394 7.59661H5.44643ZM1.57861 7.59661C1.80785 9.70467 3.2386 11.4509 5.1715 12.1388C5.07135 11.9317 4.97972 11.7098 4.89746 11.477C4.53084 10.4391 4.30224 9.0828 4.25357 7.59661H1.57861ZM9.74679 7.59661C9.69813 9.0828 9.46952 10.4391 9.1029 11.477C9.0206 11.7099 8.92818 11.9316 8.82797 12.1388C10.7613 11.4511 12.1925 9.70496 12.4218 7.59661H9.74679ZM5.1706 1.8616C3.23814 2.54963 1.80876 4.29604 1.5795 6.40376H4.25357C4.30224 4.91756 4.53083 3.56129 4.89746 2.5234C4.97968 2.29066 5.07051 2.0686 5.1706 1.8616ZM7.00018 1.54637C6.93213 1.54638 6.79503 1.5807 6.59898 1.81145C6.40332 2.04177 6.20139 2.41058 6.02136 2.92012C5.70754 3.80851 5.49461 5.02499 5.44643 6.40376H8.55394C8.50575 5.025 8.29282 3.80851 7.979 2.92012C7.79898 2.41059 7.59705 2.04177 7.40138 1.81145C7.20531 1.58067 7.06823 1.54637 7.00018 1.54637ZM8.82887 1.8616C8.92902 2.0687 9.02064 2.29053 9.1029 2.5234C9.46953 3.56129 9.69812 4.91756 9.74679 6.40376H12.4209C12.1916 4.29575 10.7618 2.54943 8.82887 1.8616Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_settings_outline_14 */
export const IconSettingsOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(68)
it('exports the full icon set (46 deepsuite + 20 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(69)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

View File

@@ -23,8 +23,13 @@ import { CONVERSATION_NS as NS } from '../../locale.ts'
/** Full row props: the toolview runtime share plus the standard locale seat. */
type SearchRowProps = ToolCallViewProps & PropsLocale<'conversation'>
const SEARCH_TITLES: Record<string, string> = {
grep: 'Grep',
glob: 'Glob',
}
/**
* Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
* Search row: icon + Grep/Glob · {summary} in the shared ToolRow chrome, with the
* completed search's card as the row's collapsed-by-default card body (a capped
* search's recovery footer rides below it, inside ToolRow). Registered under
* both `grep` and `glob`; the derived model's `kind` decides the card shape. A
@@ -40,7 +45,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
variant={model.variant}
toolName={toolName}
icon={<IconSearchOutline16 size={14} />}
title={model.title}
title={SEARCH_TITLES[toolName] ?? model.title}
// The result view's replacement title outranks the args-derived summary,
// matching the terminal card's description precedence.
summary={search?.title ?? model.summary}

View File

@@ -10,7 +10,7 @@
// summary line alone.
import type { Context } from '@deepseek-ai/cordis'
import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBrowseOutline16, IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallViewProps } from '../../contract/slots.ts'
import { webCardModel } from '../models/web-card-model.ts'
@@ -35,7 +35,8 @@ const WEB_TITLES: Record<string, string> = {
export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
const model = toolRowModel(toolName, block)
const web = webCardModel(block)
const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
// Web search uses a globe; local grep/glob keep the magnifier family.
const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconGlobeOutline14 size={14} />
return (
<ToolRow
t={t}

View File

@@ -246,7 +246,8 @@ describe('SearchRow keyed card', () => {
it('collapses to the summary row; expanding reveals the grep card', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.getByText('Search')).toBeTruthy()
expect(view.getByText('Grep')).toBeTruthy()
expect(view.queryByText('Search')).toBeNull()
// Collapsed: the card is not in the DOM until the row is expanded.
expect(searchKindOf(view.container)).toBeNull()
expect(view.queryByText(/const foo = 1/)).toBeNull()
@@ -259,6 +260,8 @@ describe('SearchRow keyed card', () => {
it('expands to the glob path card', () => {
const view = render(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
expect(view.getByText('Glob')).toBeTruthy()
expect(view.queryByText('Search')).toBeNull()
expect(searchKindOf(view.container)).toBeNull()
toggleRow(view)
expect(view.getByText('src/a.ts')).toBeTruthy()

View File

@@ -20,6 +20,7 @@ import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { webCardModel } from '../src/client/tool/models/web-card-model.ts'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
@@ -140,9 +141,11 @@ describe('chat row web body', () => {
}
it('the WebRow collapses to the summary row, expanding to the full search card', () => {
const globe = render(<IconGlobeOutline14 />).container.querySelector('svg')!.outerHTML
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
// Collapsed: the summary row alone, no card in the DOM.
expect(view.getByText('Search')).toBeTruthy()
expect(view.container.querySelector('svg')?.outerHTML).toBe(globe)
expect(view.queryByText('Titled')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)

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-workflow-run/README.md
README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5
README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e
README.md: 489715c51759b1efd2da68d3bd3e0f7788ce7ecd
README.zh.md: 326a7ae4e4b8eaad43ca7ad0d22145452af6a734

View File

@@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s
## Presentation and navigation
The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount.
The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column.
A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive.

View File

@@ -12,7 +12,7 @@
## 展示与导航
运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron并以内联状态点加状态文字表达结局不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。
运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持新活动会重新取得控制remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron并以内联状态点加状态文字表达结局不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
只有所有实时事实同时成立时,成员才可打开子 Session成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'``parentId` 等于当前 Session且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示键盘聚焦时名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。

View File

@@ -14,7 +14,6 @@
padding: 0 8px;
border-radius: 8px;
background: var(--dsw-alias-bg-module-platform);
cursor: pointer;
}
.runHeader:focus-visible {
@@ -78,7 +77,6 @@
width: 100%;
min-width: 0;
height: 32px;
cursor: pointer;
}
.phaseHeader:focus-visible {

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import {
DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState,
DisclosureRow, IconChevronRightOutline14, StateDot,
type DisclosureRowProps, type StateDotState,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
@@ -62,6 +63,36 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string {
return t(count === 1 ? 'run.members.one' : 'run.members.other', { count })
}
function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean {
return phase.members.some(member => member.status !== 'completed')
}
type StatusDisclosureProps = Omit<DisclosureRowProps, 'open' | 'expandable' | 'onToggle'>
/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */
const forcedOpenToggle = (): void => {}
function ManualDisclosure(props: StatusDisclosureProps) {
const [open, setOpen] = useState(false)
return (
<DisclosureRow
{...props}
open={open}
expandable
onToggle={() => { setOpen(value => !value) }}
/>
)
}
function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & {
/** Remount a clean Phase when its append-only member count changes between batched renders. */
readonly cleanCycleKey?: number | undefined
readonly requiresExpansion: boolean
}) {
if (!requiresExpansion) return <ManualDisclosure key={cleanCycleKey} {...props} />
return <DisclosureRow {...props} open expandable={false} onToggle={forcedOpenToggle} />
}
function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
const counts = new Map<WorkflowRunStatus, number>()
for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1)
@@ -97,21 +128,19 @@ function navigableMembers(
return result
}
function RunHeader({ count, name, onToggle, open, status, t }: {
function RunHeader({ children, count, name, requiresExpansion, status, t }: {
readonly children: ReactNode
readonly count: number
readonly name: string
readonly onToggle: () => void
readonly open: boolean
readonly requiresExpansion: boolean
readonly status: WorkflowRunStatus
readonly t: WorkflowRunPanelProps['t']
}) {
return (
<DisclosureRow
<StatusDisclosure
icon={<IconChevronRightOutline14 />}
title={t('run.title', { name })}
open={open}
expandable
onToggle={onToggle}
requiresExpansion={requiresExpansion}
expandOnRowClick
previewChevron={false}
keepContentWhenOpen
@@ -128,7 +157,9 @@ function RunHeader({ count, name, onToggle, open, status, t }: {
</span>
</>
)}
/>
>
{children}
</StatusDisclosure>
)
}
@@ -168,15 +199,12 @@ function PhaseSection({ phase, navigable, openSession, t }: {
readonly openSession: WorkflowRunInjected['openSession']
readonly t: WorkflowRunPanelProps['t']
}) {
const [open, setOpen] = useState(false)
const toggle = (): void => { setOpen(value => !value) }
return (
<DisclosureRow
<StatusDisclosure
icon={<IconChevronRightOutline14 />}
title={readablePhase(phase.phase, t)}
open={open}
expandable
onToggle={toggle}
cleanCycleKey={phase.members.length}
requiresExpansion={phaseRequiresExpansion(phase)}
expandOnRowClick
previewChevron={false}
keepContentWhenOpen
@@ -203,14 +231,15 @@ function PhaseSection({ phase, navigable, openSession, t }: {
/>
))}
</div>
</DisclosureRow>
</StatusDisclosure>
)
}
/** Render one durable workflow run with independent run and phase disclosure. */
/** Render one durable workflow run with status-driven run and phase disclosure. */
export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
const [open, setOpen] = useState(() => node.data.status === 'running')
const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
const requiresExpansion = node.data.status !== 'completed'
|| node.data.phases.some(phaseRequiresExpansion)
const navigable = useSessions(
sessions => navigableMembers(sessions, node.data.phases, sessionId),
shallowEqual,
@@ -218,14 +247,12 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
return (
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
<RunHeader
count={memberCount}
count={totalMembers}
name={node.data.name}
open={open}
requiresExpansion={requiresExpansion}
status={node.data.status}
t={t}
onToggle={() => { setOpen(value => !value) }}
/>
{open && (
>
<div className={css.phaseList}>
{node.data.phases.length === 0
? <span className={css.empty}>{t('run.empty')}</span>
@@ -239,7 +266,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
/>
))}
</div>
)}
</RunHeader>
</section>
)
}

View File

@@ -301,90 +301,170 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
}
describe('WorkflowRunPanel', () => {
it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => {
it('forces running run and phase content open without false disclosure controls', () => {
const view = render(<WorkflowRunPanel {...panelProps({
name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })],
})} />)
expect(screen.getByText('worker')).toBeTruthy()
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
const rows = [...view.container.querySelectorAll('[data-disclosure-row]')]
expect(rows).toHaveLength(2)
for (const row of rows) {
expect(row.getAttribute('role')).toBeNull()
expect(row.getAttribute('tabindex')).toBeNull()
expect(row.getAttribute('aria-expanded')).toBeNull()
expect(row.getAttribute('data-expandable')).toBeNull()
}
})
it('folds each clean transition once and preserves review choices until activity returns', () => {
const running: WorkflowRunChatData = {
name: 'audit', status: 'running', phases: [phase()],
}
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
expect(screen.getByText('未分阶段')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
expect(screen.queryByText('未分阶段')).toBeNull()
const phaseCompleted: WorkflowRunChatData = {
...running,
phases: [phase({
members: [{
seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed',
}],
})],
}
view.rerender(<WorkflowRunPanel {...panelProps(phaseCompleted)} />)
const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('done')).toBeNull()
fireEvent.click(phaseHeader)
expect(screen.getByText('done')).toBeTruthy()
const terminal: WorkflowRunChatData = { ...running, status: 'completed' }
view.rerender(<WorkflowRunPanel {...panelProps(terminal)} />)
const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' }
view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
const runHeader = screen.getByRole('button', { name: /^audit/ })
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('未分阶段')).toBeNull()
fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(runHeader, { key: 'Enter' })
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
fireEvent.keyDown(completedPhase, { key: 'Enter' })
expect(screen.getByText('done')).toBeTruthy()
fireEvent.keyDown(runHeader, { key: ' ' })
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(runHeader, { key: ' ' })
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('done')).toBeTruthy()
cleanup()
render(<WorkflowRunPanel {...panelProps(terminal)} />)
const cleanUpdate: WorkflowRunChatData = {
...completed,
phases: [phase({
members: [{
seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed',
}],
})],
}
view.rerender(<WorkflowRunPanel {...panelProps(cleanUpdate)} />)
expect(screen.getByText('reviewed')).toBeTruthy()
view.rerender(<WorkflowRunPanel {...panelProps(running)} />)
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
expect(screen.getByText('worker')).toBeTruthy()
view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('未分阶段')).toBeNull()
})
it('supports root keyboard disclosure and renders a zero-member running state', () => {
render(<WorkflowRunPanel {...panelProps({
name: 'keyboard', status: 'running',
phases: [phase({ key: 'research', phase: 'Research' })],
it('refolds a phase when a complete activity cycle arrives as one clean update', () => {
const firstMember = {
seq: 1, label: 'first', childId: 'child-1' as SessionId, status: 'completed' as const,
}
const phaseClean: WorkflowRunChatData = {
name: 'phase-cycle', status: 'running',
phases: [phase({ members: [firstMember] })],
}
const phaseView = render(<WorkflowRunPanel {...panelProps(phaseClean)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('first')).toBeTruthy()
phaseView.rerender(<WorkflowRunPanel {...panelProps({
...phaseClean,
phases: [phase({ members: [firstMember, {
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'completed',
}] })],
})} />)
const header = screen.getByRole('button', { name: /^keyboard/ })
expect(header.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(header, { key: 'ArrowDown' })
expect(header.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(header, { key: 'Enter' })
expect(header.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(header, { key: ' ' })
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByText('Research')).toBeTruthy()
expect(screen.getByText('运行中 1')).toBeTruthy()
const phaseHeader = screen.getByRole('button', { name: /Research/ })
fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(phaseHeader, { key: 'Enter' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(phaseHeader, { key: ' ' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('first')).toBeNull()
expect(screen.queryByText('second')).toBeNull()
})
cleanup()
render(<WorkflowRunPanel {...panelProps({
name: 'empty', status: 'running', phases: [],
})} />)
it('derives the zero-member running and completed states from the current run status', () => {
const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] }
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull()
expect(screen.getByText('没有启动成员')).toBeTruthy()
view.rerender(<WorkflowRunPanel {...panelProps({ ...running, status: 'completed' })} />)
const header = screen.getByRole('button', { name: /^empty/ })
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('没有启动成员')).toBeNull()
fireEvent.click(header)
expect(screen.getByText('没有启动成员')).toBeTruthy()
})
it('keeps phase disclosure independent and preserves empty versus absent names', () => {
it.each(['failed', 'cancelled', 'interrupted'] as const)(
'bubbles a %s member to the run and keeps a matching run outcome open',
(status) => {
const memberView = render(<WorkflowRunPanel {...panelProps({
name: 'member-outcome', status: 'completed',
phases: [phase({
members: [{ seq: 1, label: status, childId: CHILD_ID, status }],
})],
})} />)
expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull()
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
expect(screen.getByText(status)).toBeTruthy()
memberView.unmount()
render(<WorkflowRunPanel {...panelProps({
name: 'run-outcome', status,
phases: [phase({
members: [{ seq: 1, label: 'done', childId: CHILD_ID, status: 'completed' }],
})],
})} />)
expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull()
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('done')).toBeNull()
},
)
it('keeps clean sibling phases independent and preserves empty versus absent names', () => {
render(<WorkflowRunPanel {...panelProps({
name: 'audit', status: 'running',
name: 'audit', status: 'completed',
phases: [
phase({ key: 'value:0:', phase: '', members: [{
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'running',
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'completed',
}] }),
phase({ key: 'missing', phase: null, members: [{
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running',
}] }),
],
})} />)
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
expect(screen.getByText('空成员名')).toBeTruthy()
expect(screen.queryByText('second')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
const cleanPhase = screen.getByRole('button', { name: /空阶段名/ })
expect(cleanPhase.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
expect(screen.queryByText('空成员名')).toBeNull()
expect(screen.getByText('second')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
fireEvent.click(cleanPhase)
expect(screen.getByText('空成员名')).toBeTruthy()
expect(screen.getByText('second')).toBeTruthy()
fireEvent.click(cleanPhase)
expect(screen.queryByText('空成员名')).toBeNull()
expect(screen.getByText('second')).toBeTruthy()
})
it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => {
const completed: WorkflowRunChatData = {
name: 'repo-audit', status: 'completed',
phases: [phase({
members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }],
})],
}
const completedView = render(<WorkflowRunPanel {...panelProps(completed)} />)
const completedHeader = screen.getByRole('button', { name: /^repo-audit/ })
expect(completedHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(completedHeader)
expect(completedHeader.getAttribute('aria-expanded')).toBe('true')
completedView.unmount()
it('renders mixed and interrupted aggregate status while attention stays visible', () => {
const mixed: WorkflowRunChatData = {
name: 'repo-audit', status: 'failed',
phases: [phase({
@@ -395,8 +475,6 @@ describe('WorkflowRunPanel', () => {
})],
}
const mixedView = render(<WorkflowRunPanel {...panelProps(mixed)} />)
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
expect([...mixedView.container.querySelectorAll('[data-member-status]')]
.map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled'])
@@ -404,28 +482,18 @@ describe('WorkflowRunPanel', () => {
expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
mixedView.unmount()
const interrupted: WorkflowRunChatData = {
const interruptedView = render(<WorkflowRunPanel {...panelProps({
name: 'repo-audit', status: 'interrupted',
phases: [
phase({
members: [
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
],
}),
phase({
key: 'interrupted-only', phase: 'Interrupted only',
members: [{
seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted',
}],
}),
],
}
const interruptedView = render(<WorkflowRunPanel {...panelProps(interrupted)} />)
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
phases: [phase({
members: [
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
],
})],
})} />)
expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
})
it('opens only a running ordinary-list subagent proven to have this parent', () => {
@@ -434,7 +502,6 @@ describe('WorkflowRunPanel', () => {
}
const openSession = vi.fn()
render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
expect(openSession).toHaveBeenCalledWith('child-1')
})
@@ -464,7 +531,6 @@ describe('WorkflowRunPanel', () => {
})],
}
render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
cleanup()
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,16 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter
return { ctx, sandbox }
}
/**
* A path inside a fresh temp dir where no file is written, pinning the
* built-entry `existsSync` check to false. Without it the resolution depends on
* whether the checkout has run `build:lib:host`, which emits
* `sandbox-windows-acl/lib/runner.js`.
*/
function absentRunnerEntry(): string {
return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js')
}
/** Write an executable fake `landlock-run` that answers `--probe` with `report`. */
function fakeLauncher(report = 'landlock: fully enforced'): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
@@ -395,15 +405,31 @@ describe('the windows-acl probe (runner invocation contract)', () => {
})
it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => {
// The default probe spawns the exact runner argv confine would use — the
// runner source through tsx on a lib-less checkout. The windows-acl
// runner cannot init off win32, so the probe reads unusable and the walk
// falls through to the injected bwrap verdict on every host.
// No entry injected: this covers the production resolution through
// import.meta.resolve. Which arm of the existsSync check it takes depends
// on whether the checkout has run build:lib:host (which emits
// sandbox-windows-acl/lib/runner.js), so this asserts only what holds
// either way — the runner cannot init off win32, so the probe reads
// unusable and the walk falls through to the injected bwrap verdict.
const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true })
const confined = sandbox.confine(['true'], RO)
expect(confined.argv[0]).toBe('bwrap')
}, 30_000)
it('falls back to the runner source through tsx when the built entry is absent', async () => {
// The absent entry pins the source-through-tsx arm regardless of build
// state: on a checkout where build:lib:host has run, the real resolution
// above takes the built-entry arm instead and would leave this uncovered.
const { sandbox } = await setup({}, {
chain: ['windows-acl', 'bwrap'],
probeWindowsAcl: () => true,
windowsAclRunnerEntry: absentRunnerEntry(),
})
const confined = sandbox.confine(['true'], RO)
expect(confined.argv.slice(0, 3)).toEqual([process.execPath, '--import', 'tsx/esm'])
expect(confined.argv[3]).toMatch(/runner\.ts$/)
})
it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => {
// windowsAclRunnerInvocation always yields [node, ...] in product; an
// override returning [] exercises the default probe's empty-argv guard.