Merge remote-tracking branch 'upstream/master' into feat/web-inline-file-mentions
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-primitives/README.i18n.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
|
||||
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
|
||||
README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b
|
||||
README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { isLoopbackHostname } from './loopback-hostname.ts'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
/** The request facts the fence reads from either HTTP representation. */
|
||||
interface ApiTrustRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
headers: IncomingHttpHeaders | Headers
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined {
|
||||
if (headers instanceof Headers) return headers.get(name) ?? undefined
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
@@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
|
||||
|
||||
/**
|
||||
* Decide whether one /api request may reach the RPC bridge.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param request - Node HTTP or Fetch request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
||||
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
||||
*/
|
||||
|
||||
@@ -35,10 +35,12 @@ import type {
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
/** The fake carrier mints like a real one (business code never mints). */
|
||||
function rpcRequest<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(crypto.randomUUID()), payload }
|
||||
return { rpcId: RpcId(randomUuid()), payload }
|
||||
}
|
||||
|
||||
function text(t: string): ContentBlock[] {
|
||||
@@ -1328,6 +1330,16 @@ class FxInbox<F> implements StreamConn<F> {
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return createFixtureWorld(options).api
|
||||
}
|
||||
|
||||
interface FixtureWorld {
|
||||
readonly api: ApiProxy
|
||||
readonly rpc: ClientConnectionRpc
|
||||
}
|
||||
|
||||
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
||||
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
@@ -1506,31 +1518,141 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return backscanGoal(log) as FxGoalProjection
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
|
||||
const fxMutateGoal = (
|
||||
request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
|
||||
ref: { id: string; revision: number },
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
|
||||
const missing = requireSession(request)
|
||||
type FxGoalRef = { id: string; revision: number }
|
||||
type FxGoalView = FxGoalProjection['goal'] & {
|
||||
roundsStarted: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
activation: 'armed' | 'disarmed'
|
||||
}
|
||||
|
||||
const goalFailure = <T>(message: string): RpcResult<T> => ({
|
||||
ok: false,
|
||||
error: { code: 'internal', message, details: {} },
|
||||
})
|
||||
|
||||
const requireGoalSession = (id: SessionId): RpcResult<never> | undefined => (
|
||||
summaryOf(id) === undefined
|
||||
? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } }
|
||||
: undefined
|
||||
)
|
||||
|
||||
const goalView = (projection: FxGoalProjection): FxGoalView => ({
|
||||
...projection.goal,
|
||||
roundsStarted: projection.roundsStarted,
|
||||
createdAt: projection.createdAt,
|
||||
updatedAt: projection.updatedAt,
|
||||
activation: projection.goal.phase === 'active' ? 'armed' : 'disarmed',
|
||||
})
|
||||
|
||||
/** Canonical fixture implementation of the generated Goal Remote contract. */
|
||||
const goalRemotes = {
|
||||
create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return goalFailure(`goal "${current.goal.id}" already exists`)
|
||||
}
|
||||
const now = Date.now()
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: {
|
||||
id: `fx-goal-${logOf(id).length}`,
|
||||
revision: 1,
|
||||
objective: request.objective,
|
||||
phase: 'active',
|
||||
maxGoalRounds: request.maxGoalRounds ?? 256,
|
||||
},
|
||||
roundsStarted: 0, createdAt: now, updatedAt: now,
|
||||
})
|
||||
return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } }
|
||||
},
|
||||
edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.objective === undefined ? {} : { objective: request.objective },
|
||||
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds },
|
||||
}))
|
||||
},
|
||||
pause(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
))
|
||||
},
|
||||
resume(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
))
|
||||
},
|
||||
complete(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
))
|
||||
},
|
||||
clear(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalRef> {
|
||||
const resolved = resolveGoal(id, ref)
|
||||
if (!resolved.ok) return resolved
|
||||
const current = resolved.value
|
||||
const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 }
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(),
|
||||
})
|
||||
return { ok: true, value: tombstone }
|
||||
},
|
||||
}
|
||||
|
||||
/** Resolve one current goal revision for a canonical Remote mutation. */
|
||||
function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalProjection> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
return goalFailure('stale or missing goal revision')
|
||||
}
|
||||
return { ok: true, value: current }
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path behind the canonical Remote verbs. */
|
||||
function mutateGoal(
|
||||
id: SessionId,
|
||||
ref: FxGoalRef,
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): RpcResult<FxGoalView> {
|
||||
const resolved = resolveGoal(id, ref)
|
||||
if (!resolved.ok) return resolved
|
||||
const current = resolved.value
|
||||
const goal = next(current)
|
||||
if (goal === undefined) {
|
||||
return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
|
||||
return goalFailure(`invalid goal transition from "${current.goal.phase}"`)
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1,
|
||||
operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
|
||||
goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
return { ok: true, value: goalView(projection) }
|
||||
}
|
||||
|
||||
const mapGoalResult = <T, U>(result: RpcResult<T>, map: (value: T) => U): RpcResult<U> => (
|
||||
result.ok ? { ok: true, value: map(result.value) } : result
|
||||
)
|
||||
|
||||
const goalRefResult = (result: RpcResult<FxGoalView>): RpcResult<{ ref: { id: never; revision: number } }> => (
|
||||
mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } }))
|
||||
)
|
||||
|
||||
const legacyGoalResponse = <P, T>(request: RpcRequest<P>, result: RpcResult<T>): Promise<RpcResponse<T>> => (
|
||||
Promise.resolve({ rpcId: request.rpcId, result })
|
||||
)
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
|
||||
|
||||
@@ -1776,7 +1898,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
replays.set(id, { timer: setTimeout(tick, 80), finish })
|
||||
}
|
||||
|
||||
return {
|
||||
const api: ApiProxy = {
|
||||
sessions: {
|
||||
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
|
||||
search: (request, signal) => {
|
||||
@@ -1978,6 +2100,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
// The fixture's routes all serve; a surface exercising the blocked
|
||||
// posture drives it through its own stub.
|
||||
routable: true,
|
||||
groups: fixtureModelGroups(),
|
||||
failures: [],
|
||||
}),
|
||||
@@ -2330,60 +2455,44 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
// Mutation-only mirror of the host handlers: each verb CAS-checks the
|
||||
// projected current goal, appends the whole-value change (the mux
|
||||
// stream and projection frame ride the shared append path), and
|
||||
// acknowledges with the new ref only.
|
||||
create: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
|
||||
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
},
|
||||
edit: request => fxMutateGoal(request, request.payload.ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
pause: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
)),
|
||||
resume: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
)),
|
||||
complete: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
)),
|
||||
clear: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
}
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear',
|
||||
cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { cleared: true as const })
|
||||
},
|
||||
// Compatibility face only: old API Proxy payloads and acknowledgements
|
||||
// adapt to the canonical fixture Remote implementation above.
|
||||
create: request => legacyGoalResponse(
|
||||
request,
|
||||
mapGoalResult(
|
||||
goalRemotes.create(request.payload.sessionId, {
|
||||
objective: request.payload.objective,
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
}),
|
||||
value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }),
|
||||
),
|
||||
),
|
||||
edit: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, {
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
),
|
||||
pause: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
resume: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
complete: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
clear: request => legacyGoalResponse(
|
||||
request,
|
||||
mapGoalResult(
|
||||
goalRemotes.clear(request.payload.sessionId, request.payload.ref),
|
||||
() => ({ cleared: true as const }),
|
||||
),
|
||||
),
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
@@ -2503,8 +2612,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
providers: request => ok(request, {
|
||||
providers: [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false },
|
||||
// One hand-declared route, so a surface reading this fixture meets
|
||||
// the tagged shape rather than only the shipped one.
|
||||
{ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true },
|
||||
],
|
||||
}),
|
||||
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
|
||||
@@ -2541,6 +2653,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
}
|
||||
|
||||
const rpc: ClientConnectionRpc = {
|
||||
call(channel, endpoint, payload) {
|
||||
if (channel !== '/api') {
|
||||
return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`))
|
||||
}
|
||||
const args = (payload as {
|
||||
args: {
|
||||
agentId: SessionId
|
||||
ref?: { id: string; revision: number }
|
||||
request?: { objective?: string; maxGoalRounds?: number }
|
||||
}
|
||||
}).args
|
||||
const sessionId = args.agentId
|
||||
switch (endpoint) {
|
||||
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
|
||||
objective: args.request?.objective as string,
|
||||
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
|
||||
}))
|
||||
case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {}))
|
||||
case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef))
|
||||
default:
|
||||
return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`))
|
||||
}
|
||||
},
|
||||
}
|
||||
return { api, rpc }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2552,10 +2694,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
*/
|
||||
export class FixtureApiClient extends AbstractApiClient {
|
||||
private readonly api: ApiProxy
|
||||
/** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.api = createFixtureApi(fixtureOptionsFromLocation())
|
||||
const world = createFixtureWorld(fixtureOptionsFromLocation())
|
||||
this.api = world.api
|
||||
this.rpc = world.rpc
|
||||
}
|
||||
|
||||
protected doFetch(): Promise<Response> {
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { createWebConnectionRpc } from './rpc.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
@@ -36,6 +38,7 @@ export {
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
export type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
@@ -51,6 +54,8 @@ export interface ConnectionHandle {
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/** Generic logical RPC channels over the same Connection transport. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -69,11 +74,14 @@ export interface ConnectionHandle {
|
||||
export function apply(ctx: Context): void {
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
|
||||
const fixtureClient = fixture ? new FixtureApiClient() : undefined
|
||||
const api: IApiClient = fixtureClient ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
rpc,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
14
packages/client/connection/src/client/random-uuid.ts
Normal file
14
packages/client/connection/src/client/random-uuid.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Browser-safe UUID generation for client-side wire correlation. */
|
||||
|
||||
/**
|
||||
* Generate an RFC 4122 version 4 UUID without requiring a secure context.
|
||||
* @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins.
|
||||
*/
|
||||
export function randomUuid(): string {
|
||||
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
|
||||
view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
63
packages/client/connection/src/client/rpc.ts
Normal file
63
packages/client/connection/src/client/rpc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Browser caller for generic Connection unary RPC channels. */
|
||||
|
||||
import {
|
||||
RpcId,
|
||||
serverResponseSchema,
|
||||
type ClientRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
/**
|
||||
* Create the browser-backed generic RPC caller.
|
||||
* @returns caller that owns request correlation and response-envelope validation.
|
||||
*/
|
||||
export function createWebConnectionRpc(): ClientConnectionRpc {
|
||||
return {
|
||||
async call(channel, endpoint, payload, signal) {
|
||||
assertTarget(channel, endpoint)
|
||||
const rpcId = RpcId(randomUuid())
|
||||
const message: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId,
|
||||
method: endpoint,
|
||||
payload,
|
||||
}
|
||||
const response = await globalThis.fetch(
|
||||
new URL(`${channel}/${endpoint}`, resolveBase()),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(message),
|
||||
...signal === undefined ? {} : { signal },
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
|
||||
}
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
if (full.rpcId !== rpcId) {
|
||||
throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
|
||||
}
|
||||
return full.result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBase(): string {
|
||||
const location = (globalThis as { location?: { origin?: string } }).location
|
||||
return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
|
||||
}
|
||||
|
||||
function assertTarget(channel: string, endpoint: string): void {
|
||||
const segments = endpoint.split('/')
|
||||
if (!CHANNEL_PATTERN.test(channel)
|
||||
|| segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
/** Transport-independent request handler consumed by the Host HTTP bridge. */
|
||||
export interface FetchHandler {
|
||||
/**
|
||||
* Handle one standard Fetch request.
|
||||
* @param request - request produced by the active transport bridge.
|
||||
* @returns complete or streaming Fetch response.
|
||||
*/
|
||||
fetch(request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge one node:http request to the fetch-shaped handler (client close
|
||||
* aborts; SSE bodies stream out chunk by chunk).
|
||||
@@ -12,7 +22,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
* @param res - node:http response the bridge writes and owns to completion.
|
||||
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
||||
*/
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
|
||||
@@ -7,15 +7,26 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { HostConnectionService } from './rpc-host.ts'
|
||||
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
||||
|
||||
export type {
|
||||
ConnectionRpcAuthority,
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
export { HostConnectionService } from './rpc-host.ts'
|
||||
|
||||
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
export interface ConnectionConfig {
|
||||
@@ -83,49 +94,61 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
|
||||
const connection = new HostConnectionService(ctx, trustedHosts)
|
||||
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
if (method !== undefined
|
||||
&& PRIVILEGED_METHODS.has(method)
|
||||
&& !isTrustedApiRequest(request, [])) {
|
||||
return new Response('forbidden', { status: 403 })
|
||||
}
|
||||
if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
return new Response('upgrade required', {
|
||||
status: 426,
|
||||
headers: { connection: 'Upgrade', upgrade: 'websocket' },
|
||||
})
|
||||
}
|
||||
const apiProxy = ctx.get('apiProxy')
|
||||
if (apiProxy === undefined) return new Response('not found', { status: 404 })
|
||||
return toFetchHandler(apiProxy).fetch(request)
|
||||
},
|
||||
})
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
|
||||
? isTrustedApiRequest(req, [])
|
||||
: isTrustedApiRequest(req, trustedHosts)
|
||||
if (!allowed) {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
||||
res.end('upgrade required')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
ctx.inject(['apiProxy'], (apiCtx) => {
|
||||
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
})
|
||||
}
|
||||
|
||||
224
packages/client/connection/src/rpc-host.ts
Normal file
224
packages/client/connection/src/rpc-host.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
RpcId,
|
||||
type ClientRequest,
|
||||
type RpcError,
|
||||
type RpcErrorDetailsMap,
|
||||
type RpcId as RpcIdType,
|
||||
type ServerResponse as RpcServerResponse,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { bridge, type FetchHandler } from './http-bridge.ts'
|
||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import type {
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
|
||||
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
interface ConnectionRpcInterceptor {
|
||||
readonly matches: ConnectionRpcEndpointMatcher
|
||||
readonly fetchHandler: FetchHandler
|
||||
readonly options: ConnectionRpcHandlerOptions
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host Connection transport and RPC registrations. */
|
||||
connection: HostConnectionHandle
|
||||
}
|
||||
}
|
||||
|
||||
/** Host Connection service whose channel registrations belong to the caller fiber. */
|
||||
export class HostConnectionService extends Service implements HostConnectionHandle {
|
||||
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
|
||||
|
||||
/**
|
||||
* Provide the Host half over the active HTTP server.
|
||||
* @param ctx - owning Connection plugin context.
|
||||
* @param trustedHosts - deployment authorities accepted by trusted-host channels.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
|
||||
super(ctx, 'connection')
|
||||
}
|
||||
|
||||
/** Generic channel registry scoped to the Context reading this service. */
|
||||
get rpc(): HostConnectionRpc {
|
||||
const owner = this.ctx
|
||||
return {
|
||||
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
|
||||
intercept: (channel, matches, handler, options) =>
|
||||
this.registerInterceptor(owner, channel, matches, handler, options),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one shared-channel Fetch handler from its interceptor and fallback.
|
||||
* @param channel - shared channel mounted by Connection.
|
||||
* @param fallback - handler for endpoints not claimed by the interceptor.
|
||||
* @returns Fetch handler that selects exactly one target for each request.
|
||||
*/
|
||||
createSharedFetchHandler(
|
||||
channel: '/api',
|
||||
fallback: FetchHandler,
|
||||
): FetchHandler {
|
||||
return {
|
||||
fetch: (request) => {
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
const interceptor = this.interceptors.get(channel)
|
||||
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
|
||||
return fallback.fetch(request)
|
||||
}
|
||||
if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) {
|
||||
return Promise.resolve(new Response('forbidden', { status: 403 }))
|
||||
}
|
||||
return interceptor.fetchHandler.fetch(request)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private register(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
assertChannel(channel)
|
||||
const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
|
||||
const fetchHandler = rpcFetchHandler(channel, handler)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: channel,
|
||||
handler: async (req, res) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
return owner.effect(
|
||||
() => owner.httpServer.register(route),
|
||||
`client-connection: ${channel} rpc channel`,
|
||||
)
|
||||
}
|
||||
|
||||
private registerInterceptor(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
matches: ConnectionRpcEndpointMatcher,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
if (channel !== API_PATH) {
|
||||
throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
const interceptor: ConnectionRpcInterceptor = {
|
||||
matches,
|
||||
fetchHandler: rpcFetchHandler(channel, handler),
|
||||
options,
|
||||
}
|
||||
return owner.effect(() => {
|
||||
if (this.interceptors.has(channel)) {
|
||||
throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`)
|
||||
}
|
||||
this.interceptors.set(channel, interceptor)
|
||||
return () => {
|
||||
this.interceptors.delete(channel)
|
||||
}
|
||||
}, `client-connection: ${channel} rpc interceptor`)
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFetchHandler(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
): FetchHandler {
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
if (request.method !== 'POST' || endpoint === undefined) {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
|
||||
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
if (mediaType !== 'application/json') {
|
||||
return new Response('content type must be application/json', { status: 415 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return new Response('body is not JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const envelope = clientRequestSchema.safeParse(body)
|
||||
if (!envelope.success) {
|
||||
return invalidEnvelopeResponse(body, envelope.error.issues)
|
||||
}
|
||||
const message: ClientRequest = envelope.data
|
||||
if (message.method !== endpoint) {
|
||||
return errorResponse(message.rpcId, {
|
||||
code: 'bad-request',
|
||||
message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
||||
details: { issues: [] },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(endpoint, message.payload, request.signal)
|
||||
return fullResponse(message.rpcId, result)
|
||||
} catch (error) {
|
||||
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response {
|
||||
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
|
||||
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
|
||||
return errorResponse(rpcId, {
|
||||
code: 'bad-request',
|
||||
message: 'invalid client-request message',
|
||||
details: { issues },
|
||||
})
|
||||
}
|
||||
|
||||
function endpointFromPath(channel: string, pathname: string): string | undefined {
|
||||
if (!pathname.startsWith(`${channel}/`)) return undefined
|
||||
const endpoint = pathname.slice(channel.length + 1)
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
return undefined
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
|
||||
return fullResponse(rpcId, { ok: false, error })
|
||||
}
|
||||
|
||||
function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
|
||||
const body: RpcServerResponse = { type: 'server-response', rpcId, result }
|
||||
return Response.json(body)
|
||||
}
|
||||
|
||||
function assertChannel(channel: string): void {
|
||||
if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
|
||||
throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
}
|
||||
77
packages/client/connection/src/rpc.ts
Normal file
77
packages/client/connection/src/rpc.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
||||
|
||||
import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/** Trust fence applied before a Host RPC channel reaches its handler. */
|
||||
export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
|
||||
|
||||
/** Registration policy for one logical RPC channel. */
|
||||
export interface ConnectionRpcHandlerOptions {
|
||||
/** Browser authority accepted by every endpoint in this channel. */
|
||||
readonly authority: ConnectionRpcAuthority
|
||||
}
|
||||
|
||||
/** Handler invoked after Connection has decoded the transport envelope. */
|
||||
export type ConnectionRpcHandler = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<RpcResult<unknown>>
|
||||
|
||||
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
|
||||
export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
|
||||
|
||||
/** Host registry for logical RPC channels carried by the current transport. */
|
||||
export interface HostConnectionRpc {
|
||||
/**
|
||||
* Register one absolute channel prefix and its trust policy.
|
||||
* @param channel - absolute logical channel such as `/rpc`.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - channel trust policy.
|
||||
* @returns asynchronous disposer removing the channel and its physical route.
|
||||
*/
|
||||
handle(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
|
||||
/**
|
||||
* Intercept owned endpoints on the shared `/api` channel before its fallback.
|
||||
* @param channel - reserved shared channel; currently `/api`.
|
||||
* @param matches - synchronous endpoint ownership test.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - trust policy for every endpoint claimed by this interceptor.
|
||||
* @returns asynchronous disposer removing the interceptor.
|
||||
*/
|
||||
intercept(
|
||||
channel: '/api',
|
||||
matches: ConnectionRpcEndpointMatcher,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
}
|
||||
|
||||
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
|
||||
export interface HostConnectionHandle {
|
||||
/** Generic RPC channel registry. */
|
||||
readonly rpc: HostConnectionRpc
|
||||
}
|
||||
|
||||
/** Client caller for logical RPC channels carried by the current transport. */
|
||||
export interface ClientConnectionRpc {
|
||||
/**
|
||||
* Call one endpoint through an already registered logical channel.
|
||||
* @param channel - absolute logical channel such as `/api`.
|
||||
* @param endpoint - channel-relative endpoint such as `goals/create`.
|
||||
* @param payload - channel-owned request payload.
|
||||
* @param signal - optional caller cancellation.
|
||||
* @returns the existing RPC success/error result; correlation stays inside Connection.
|
||||
*/
|
||||
call(
|
||||
channel: string,
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResult<unknown>>
|
||||
}
|
||||
@@ -203,4 +203,119 @@ describe('connection client apply', () => {
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
})
|
||||
|
||||
it('carries RPC calls without requiring secure-context randomUUID', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues(bytes: Uint8Array) {
|
||||
return bytes.fill(0)
|
||||
},
|
||||
})
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: { url: string; body: unknown }[] = []
|
||||
globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||
if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
|
||||
const body = JSON.parse(init.body) as { rpcId: string }
|
||||
seen.push({ url, body })
|
||||
return Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: body.rpcId,
|
||||
result: { ok: true, value: { ref: 'goal-1' } },
|
||||
})
|
||||
}
|
||||
try {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
|
||||
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
|
||||
expect(seen[0]?.body).toMatchObject({
|
||||
type: 'client-request',
|
||||
rpcId: '00000000-0000-4000-8000-000000000000',
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('validates generic RPC transport failures, correlation, and targets', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'harness.example', search: '', origin: 'https://harness.example',
|
||||
}
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const abort = new AbortController()
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
|
||||
try {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
|
||||
.rejects.toThrow('HTTP 503')
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
new URL('https://harness.example/api/goals/create'),
|
||||
expect.objectContaining({ signal: abort.signal }),
|
||||
)
|
||||
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' }
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: 'different-rpc',
|
||||
result: { ok: true, value: null },
|
||||
}))
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
|
||||
const fetch = vi.mocked(globalThis.fetch)
|
||||
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
|
||||
expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
|
||||
for (const [channel, endpoint] of [
|
||||
['api2', 'goals/create'],
|
||||
['/api/path', 'goals/create'],
|
||||
['/api', ''],
|
||||
['/api', '.'],
|
||||
['/api', '..'],
|
||||
['/api', 'goals//create'],
|
||||
['/api', 'goals/create?unsafe'],
|
||||
] as const) {
|
||||
await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
|
||||
}
|
||||
})
|
||||
|
||||
it('carries Goal Remotes over the same state as the client-only fixture API', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const created = await handle.rpc.call('/api', 'goals/create', {
|
||||
args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } },
|
||||
})
|
||||
expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } })
|
||||
if (!created.ok) throw new Error('fixture Goal create failed')
|
||||
const ref = (created.value as { ref: { id: string; revision: number } }).ref
|
||||
const edited = await handle.rpc.call('/api', 'goals/edit', {
|
||||
args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } },
|
||||
})
|
||||
expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } })
|
||||
const editedRef = { id: ref.id, revision: 2 }
|
||||
const paused = await handle.rpc.call('/api', 'goals/pause', {
|
||||
args: { agentId: 'fx-alpha', ref: editedRef },
|
||||
})
|
||||
expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } })
|
||||
const resumed = await handle.rpc.call('/api', 'goals/resume', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } },
|
||||
})
|
||||
expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } })
|
||||
const completed = await handle.rpc.call('/api', 'goals/complete', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } },
|
||||
})
|
||||
expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } })
|
||||
await expect(handle.rpc.call('/api', 'goals/clear', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } },
|
||||
})).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } })
|
||||
await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/)
|
||||
await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } }))
|
||||
.rejects.toThrow(/endpoint.*unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,6 +59,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => {
|
||||
let carrierSignal: AbortSignal | undefined
|
||||
const pending = bridge(request, response, {
|
||||
fetch: async (input) => {
|
||||
const fetchRequest = input as Request
|
||||
const fetchRequest = input
|
||||
carrierSignal = fetchRequest.signal
|
||||
resolveStarted()
|
||||
if (!fetchRequest.signal.aborted) {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
@@ -17,6 +18,9 @@ function fakeHttpServer(
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
@@ -36,15 +40,32 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
|
||||
return request
|
||||
}
|
||||
|
||||
/** JSON POST carrying a complete client-request envelope. */
|
||||
function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Raw POST for malformed-body and media-type boundary cases. */
|
||||
function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const chunks: Buffer[] = []
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
|
||||
else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
|
||||
if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
@@ -173,6 +194,211 @@ describe('connection node half', () => {
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
}, { authority: 'trusted-host' })
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')
|
||||
expect(route).toBeDefined()
|
||||
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-dedicated'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
const result = fakeResponse()
|
||||
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
|
||||
expect(result.state.status).toBe(200)
|
||||
expect(JSON.parse(String(result.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-dedicated',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
|
||||
authority: 'trusted-host',
|
||||
})).toThrow(/duplicate route/)
|
||||
await remove()
|
||||
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => endpoint === 'goals/create',
|
||||
async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
},
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
expect(() => connection.rpc.intercept(
|
||||
'/api',
|
||||
() => true,
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'trusted-host' },
|
||||
)).toThrow('already has an interceptor')
|
||||
expect(() => connection.rpc.intercept(
|
||||
'/rpc' as '/api',
|
||||
() => true,
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'trusted-host' },
|
||||
)).toThrow('invalid shared RPC channel')
|
||||
const route = routes.find(candidate => candidate.path === API_PATH)!
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-shared'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
|
||||
const claimed = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
|
||||
expect(JSON.parse(String(claimed.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-shared',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const unclaimed = fakeResponse()
|
||||
await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
|
||||
expect(unclaimed.state.status).toBe(404)
|
||||
|
||||
await remove()
|
||||
const withdrawn = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
|
||||
expect(withdrawn.state.status).toBe(404)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const removeLoopback = connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => endpoint === 'goals/create',
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'loopback' },
|
||||
)
|
||||
const loopbackOnly = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
|
||||
expect(loopbackOnly.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const remove = connection.rpc.handle('/rpc', async (endpoint) => {
|
||||
if (endpoint === 'fail') throw new Error('handler broke')
|
||||
return { ok: true, value: null }
|
||||
}, {
|
||||
authority: 'trusted-host',
|
||||
})
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')!
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
|
||||
const methodMismatch = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
|
||||
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
|
||||
}), methodMismatch.response)
|
||||
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
|
||||
rpcId: 'rpc-bad',
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
|
||||
for (const [request, status] of [
|
||||
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
|
||||
[fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
|
||||
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
|
||||
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
await route.handler(request, response.response)
|
||||
expect(response.state.status).toBe(status)
|
||||
}
|
||||
|
||||
for (const [body, rpcId] of [
|
||||
[{ rpcId: 'retained-id' }, 'retained-id'],
|
||||
[{ rpcId: 42 }, 'invalid-request'],
|
||||
[null, 'invalid-request'],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
|
||||
expect(JSON.parse(String(response.state.body))).toMatchObject({
|
||||
rpcId,
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
}
|
||||
|
||||
const failed = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
|
||||
type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
|
||||
}), failed.response)
|
||||
expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
|
||||
|
||||
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
|
||||
const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})
|
||||
const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
|
||||
const publicResponse = fakeResponse()
|
||||
await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
|
||||
type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
|
||||
}), publicResponse.response)
|
||||
expect(publicResponse.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await remove()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
@@ -47,11 +49,17 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-api-remotes": "^0.0.1",
|
||||
"@deepseek-ai/dsh-type-meta": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
|
||||
export type AgentContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'agent'>
|
||||
}
|
||||
|
||||
/** Context tag written by {@link createScope}. */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
@@ -29,7 +35,7 @@ export interface AgentScopeHandle {
|
||||
* through it (passing it as the dispatch subject routes to this agent's
|
||||
* tagged listeners plus every untagged one).
|
||||
*/
|
||||
ctx: Context
|
||||
ctx: AgentContext
|
||||
/** Backing fiber (dispose tears down every scope-owned registration). */
|
||||
fiber: Fiber
|
||||
}
|
||||
@@ -48,15 +54,16 @@ function agentScope(): void {}
|
||||
*/
|
||||
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
|
||||
const fiber = ctx.plugin(agentScope)
|
||||
const scoped = fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}) as AgentContext
|
||||
return {
|
||||
fiber,
|
||||
ctx: fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}),
|
||||
ctx: scoped,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RpcResult, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AgentContext } from '../agents/scope.ts'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
@@ -19,6 +20,8 @@ import type {
|
||||
import type { SessionFace } from './session.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
export type { AgentContext } from '../agents/scope.ts'
|
||||
|
||||
/** The sessions-service face injected as `ctx.sessions`. */
|
||||
export interface ISessions {
|
||||
/** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
|
||||
@@ -95,7 +98,7 @@ export interface ISessions {
|
||||
* @param id - session id.
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined
|
||||
scope(id: SessionId): AgentContext | undefined
|
||||
/**
|
||||
* Read the Agent scope tag off a context (service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions).
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
@@ -26,7 +28,7 @@ export type { ISession, ProjectionsFace, SessionFace } from './contract/session.
|
||||
export type {
|
||||
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from './contract/session-history.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export type { AgentContext, ISessions } from './contract/sessions.ts'
|
||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
@@ -75,6 +77,13 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
export type ClientContext = Context
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
/** Client Agent scope identity; the agent and session share one wire id. */
|
||||
agent: TypeRTContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
@@ -170,8 +179,8 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
/** Required services: the Remote root, wire handle, and Client TypeRT registry. */
|
||||
export const inject = ['remote', 'connection', 'typert']
|
||||
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
@@ -180,6 +189,9 @@ export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
ctx.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => sessions.scopeOf(candidate),
|
||||
})
|
||||
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type { ISessions } from '../contract/sessions.ts'
|
||||
import type { AgentContext, ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
|
||||
@@ -127,7 +127,7 @@ export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
/** The outward session face only — feature code never sees the concrete class. */
|
||||
readonly session: SessionFace
|
||||
readonly ctx: Context
|
||||
readonly ctx: AgentContext
|
||||
}
|
||||
|
||||
// Scope primitives live in ../agents/scope.ts (the client mirror of host
|
||||
@@ -182,7 +182,7 @@ function increasedForkTitle(title: string): string {
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
ctx: AgentContext
|
||||
binding: SessionBinding
|
||||
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
|
||||
session: Session
|
||||
@@ -483,7 +483,7 @@ export class SessionsService implements ISessions {
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined {
|
||||
scope(id: SessionId): AgentContext | undefined {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
@@ -22,17 +23,22 @@ interface Bench {
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: this.defaultModel,
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
@@ -16,17 +17,22 @@ interface Bench {
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
@@ -43,6 +46,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Context } from 'cordis'
|
||||
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
|
||||
SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -134,7 +134,7 @@ interface SessionRecord {
|
||||
summary: SessionSummary
|
||||
snapshot: SnapshotStore<ConversationSnapshot>
|
||||
session: FixtureSession
|
||||
scope: Context | undefined
|
||||
scope: AgentContext | undefined
|
||||
scopeFiber: { dispose(): Promise<void> } | undefined
|
||||
/** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
|
||||
provideInfo: SessionProvideInfo | undefined
|
||||
@@ -144,7 +144,7 @@ interface SessionRecord {
|
||||
export interface TestSessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: FixtureSession
|
||||
readonly ctx: Context
|
||||
readonly ctx: AgentContext
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,7 +345,7 @@ export class TestSessions implements ISessions {
|
||||
* @param id - session id.
|
||||
* @returns the scoped context, or undefined for unknown sessions.
|
||||
*/
|
||||
scope(id: string): Context | undefined {
|
||||
scope(id: string): AgentContext | undefined {
|
||||
const record = this.records.get(id as SessionId)
|
||||
if (record === undefined) return undefined
|
||||
if (record.scope === undefined) {
|
||||
|
||||
@@ -31,6 +31,9 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
*/
|
||||
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */
|
||||
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
|
||||
|
||||
/**
|
||||
* Documented TEMPORARY exemption, not a platform module (hence not in
|
||||
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
|
||||
@@ -126,9 +129,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
resolveId(source: string) {
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 6182fa1281eec50f8898634cb466291b6ef1f5ff
|
||||
README.zh.md: 8b110ec7713dcda5972d0ea0d9a8042301906d99
|
||||
README.md: 2fe73dfa59d34dbb24389b412a01347aa2d14398
|
||||
README.zh.md: da02d1dadff8ea6187e33396a139eaca58dab6e6
|
||||
|
||||
@@ -8,13 +8,15 @@ Compaction renders as one collapsed row at the checkpoint's flow position withou
|
||||
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||
|
||||
Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite.
|
||||
|
||||
The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
|
||||
别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份契约里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。
|
||||
|
||||
视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { ComposerBlockRegistry } from './input/blocks.ts'
|
||||
import type { ComposerBlock } from './input/blocks.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
@@ -54,6 +56,11 @@ const ABSENT_NOTICES = {
|
||||
getSnapshot: (): InputNotice | null => null,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
/** No session, therefore nothing to block; same one-identity rule as above. */
|
||||
const ABSENT_BLOCK = {
|
||||
getSnapshot: (): ComposerBlock | undefined => undefined,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
|
||||
const ABSENT_LEXICON = {
|
||||
getSnapshot: () => EMPTY_LEXICON,
|
||||
@@ -133,6 +140,12 @@ export function apply(ctx: Context): void {
|
||||
// ctx.conversation.input by the service below sharing this one instance).
|
||||
const inputHub = new InputHub(ctx)
|
||||
|
||||
// The composer-block registry: a plugin that knows a session cannot send —
|
||||
// ui-model, when no adapter serves the session's route — raises a block
|
||||
// here, and the bar reads its own session's store. It cannot flow the other
|
||||
// way: this package must not import the plugins that would know.
|
||||
const composerBlocks = new ComposerBlockRegistry()
|
||||
|
||||
// Decision 19/20: the input machine feeds every session-scope slot
|
||||
// component through the standard provide channel — the 'input' hook plus
|
||||
// the two public actions. Materialization is the shell creation trigger
|
||||
@@ -167,6 +180,7 @@ export function apply(ctx: Context): void {
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
|
||||
selectWorkspace: async (workspaceId) => {
|
||||
const nextId = await workspaces.connectWorkspace(workspaceId)
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
@@ -353,7 +367,7 @@ export function apply(ctx: Context): void {
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
// Presentation registrants depend directly on their slot declarations;
|
||||
// this service remains only where conversation actions are required.
|
||||
ctx.plugin(ConversationService, { input: inputHub })
|
||||
ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks })
|
||||
|
||||
// The bash sample rides the same declaration seam, in third-party posture
|
||||
// (ToolRow-matching Bash · {description} chrome).
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
|
||||
@@ -274,6 +275,12 @@ export interface ConversationInjected {
|
||||
* When a blank session is already current, carry its draft to the target.
|
||||
*/
|
||||
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Framework-bound sources. `composerBlock` is this session's block when a
|
||||
* plugin raised one; the reason is the blocker's own localized copy, which
|
||||
* the root renders as the inert composer's placeholder.
|
||||
*/
|
||||
hooks: { composerBlock: ObservableSnapshot<ComposerBlock | undefined> }
|
||||
}
|
||||
|
||||
/** Business callbacks injected into the strict Session body seat. */
|
||||
@@ -309,6 +316,14 @@ export interface ConversationSessionHeaderInjected {
|
||||
export interface ComposerBarOwnerProps {
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
/**
|
||||
* A block another plugin raised for this session: the bar refuses input and
|
||||
* shows the blocker's reason as the placeholder, but — unlike `disabled` —
|
||||
* keeps the model seat live. Every block this contract has is one the user
|
||||
* clears by choosing a model, so locking that seat too would leave the
|
||||
* composer telling them to do the one thing it prevents.
|
||||
*/
|
||||
blocked?: { readonly reason: string }
|
||||
/**
|
||||
* Inert no-workspace state: the bar renders its normal DOM fully disabled
|
||||
* (textarea, add, send) so the workspace pick transitions in place instead
|
||||
@@ -407,7 +422,7 @@ export type ConversationSlotProps =
|
||||
| 'conversation.input.left' | 'conversation.input.right'
|
||||
| 'conversation.hero.workspace'
|
||||
>
|
||||
& ConversationInjected
|
||||
& InjectFace<ConversationInjected>
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
/** Full strict-session body props: per-session store, view ring, and draft mirror. */
|
||||
|
||||
77
packages/client/ui-conversation/src/client/input/blocks.ts
Normal file
77
packages/client/ui-conversation/src/client/input/blocks.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Composer blocks: the one way another plugin stops a session's input.
|
||||
*
|
||||
* The composer cannot read the plugins that would know — the dependency runs
|
||||
* ui-model → ui-conversation, never back — so a blocker pushes here and the
|
||||
* bar reads its own session's store. A block carries the localized reason it
|
||||
* exists, because the plugin that raised it owns that copy; the composer only
|
||||
* knows how to render an inert textarea with a placeholder, exactly as it
|
||||
* already does for a session with no workspace.
|
||||
*
|
||||
* This is an affordance, not enforcement: the Host refuses a prompt it cannot
|
||||
* route regardless of what any client disables.
|
||||
*/
|
||||
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Why one session's composer is inert. */
|
||||
export interface ComposerBlock {
|
||||
/**
|
||||
* Localized placeholder replacing the composer's own, owned by the plugin
|
||||
* that raised the block.
|
||||
*/
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
/** The registry face other plugins reach through `ctx.conversation.blocks`. */
|
||||
export interface ComposerBlocks {
|
||||
/**
|
||||
* Raise or clear this session's block. Idempotent: setting a block equal to
|
||||
* the current one, or clearing an absent one, notifies nobody.
|
||||
* @param sessionId - the session whose composer is affected.
|
||||
* @param block - the block to raise, or undefined to clear it.
|
||||
*/
|
||||
set(sessionId: SessionId, block: ComposerBlock | undefined): void
|
||||
/**
|
||||
* The store the composer subscribes to for one session. Created on first
|
||||
* read from either side, so a blocker may raise a block before the session's
|
||||
* composer mounts and the composer still sees it.
|
||||
* @param sessionId - the session to observe.
|
||||
* @returns that session's block store (undefined value = not blocked).
|
||||
*/
|
||||
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined>
|
||||
/**
|
||||
* Drop one session's store. The session scope's disposer calls this; a
|
||||
* blocker never needs to.
|
||||
* @param sessionId - the session being torn down.
|
||||
*/
|
||||
forget(sessionId: SessionId): void
|
||||
}
|
||||
|
||||
/** The per-session composer-block registry (one instance per plugin fiber). */
|
||||
export class ComposerBlockRegistry implements ComposerBlocks {
|
||||
private readonly stores = new Map<SessionId, SnapshotStore<ComposerBlock | undefined>>()
|
||||
|
||||
/** @inheritdoc */
|
||||
set(sessionId: SessionId, block: ComposerBlock | undefined): void {
|
||||
const store = this.storeFor(sessionId)
|
||||
const current = store.getSnapshot()
|
||||
if (current?.reason === block?.reason) return
|
||||
store.set(block)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined> {
|
||||
const existing = this.stores.get(sessionId)
|
||||
if (existing !== undefined) return existing
|
||||
const created = createSnapshotStore<ComposerBlock | undefined>(undefined)
|
||||
this.stores.set(sessionId, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
forget(sessionId: SessionId): void {
|
||||
this.stores.delete(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const zh = {
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'access.confirm.cancel': '取消',
|
||||
'access.confirm.enable': '启用 Full access',
|
||||
'hero.headline': '开始构建吧',
|
||||
'hero.headline': '探索未知之境',
|
||||
'hero.preview': '预览版',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
@@ -184,7 +184,7 @@ export const en = {
|
||||
'access.confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'access.confirm.cancel': 'Cancel',
|
||||
'access.confirm.enable': 'Enable Full access',
|
||||
'hero.headline': 'Let\'s start building',
|
||||
'hero.headline': 'Into the Unknown',
|
||||
'hero.preview': 'Preview',
|
||||
'hero.chooseWorkspace': 'Choose workspace',
|
||||
'session.hierarchy': 'Session hierarchy',
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Context } from 'cordis'
|
||||
// method) instead of the standalone helper.
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { QueueAction, QueueItemId } from './contract/queue.ts'
|
||||
import type { ComposerBlocks } from './input/blocks.ts'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,11 @@ import type { InputService } from './input/contract.ts'
|
||||
export interface IConversation {
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/**
|
||||
* The per-session composer-block registry: how a plugin the composer
|
||||
* cannot import makes a session's input inert with its own reason.
|
||||
*/
|
||||
readonly blocks: ComposerBlocks
|
||||
/**
|
||||
* Send a prompt into the caller scope's session (queued turn).
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
@@ -53,16 +59,20 @@ export interface IConversation {
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face, design §5.2). */
|
||||
readonly input: InputService
|
||||
/** The per-session composer-block registry. */
|
||||
readonly blocks: ComposerBlocks
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
* registers itself and follows that fiber's lifetime).
|
||||
* @param config - carries the InputService instance constructed by the
|
||||
* plugin apply (the same InputHub the slot inject factories close over).
|
||||
* @param config - carries the InputService and composer-block registry
|
||||
* constructed by the plugin apply (the same instances the slot inject
|
||||
* factories close over).
|
||||
*/
|
||||
constructor(ctx: Context, config: { input: InputService }) {
|
||||
constructor(ctx: Context, config: { input: InputService; blocks: ComposerBlocks }) {
|
||||
super(ctx, 'conversation')
|
||||
this.input = config.input
|
||||
this.blocks = config.blocks
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import css from './ConversationRoot.module.css'
|
||||
export type ConversationRootProps = ConversationSlotProps
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useWorkspaces, useInput,
|
||||
sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock,
|
||||
renderSlot, renderSlotChain, selectWorkspace, t,
|
||||
}: ConversationRootProps) {
|
||||
const openState = useSession(s => s.openState)
|
||||
@@ -24,6 +24,9 @@ export function ConversationRoot({
|
||||
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
|
||||
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
|
||||
const workspaces = useWorkspaces(s => s)
|
||||
// A plugin this package cannot import (ui-model) says this session cannot
|
||||
// send; its reason is already localized by whoever raised it.
|
||||
const composerBlock = useComposerBlock(block => block)
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
|
||||
@@ -126,11 +129,20 @@ export function ConversationRoot({
|
||||
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
|
||||
// not a different tree, so the textarea DOM survives the transition.
|
||||
const inert = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
// A raised block is the same inert posture with the blocker's own reason:
|
||||
// one disabled textarea, never a second tree. The no-workspace state wins
|
||||
// when both hold — picking a workspace is the earlier prerequisite.
|
||||
const blocked = !inert && composerBlock !== undefined
|
||||
const inputBar = renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(inert
|
||||
? { disabled: true, placeholder: t('placeholder.workspace') }
|
||||
: hero ? { placeholder: t('placeholder.hero') } : {}),
|
||||
: blocked
|
||||
// `blocked`, not `disabled`: the bar refuses input either way, but a
|
||||
// block keeps the model seat live because choosing a model is how the
|
||||
// user clears it.
|
||||
? { blocked: composerBlock, placeholder: composerBlock.reason }
|
||||
: hero ? { placeholder: t('placeholder.hero') } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
|
||||
badge is a product addition outside that source and aligns to the title. */
|
||||
badge is a product addition outside that source: a mono superscript pill
|
||||
riding the title's top-right. */
|
||||
.headline {
|
||||
display: grid;
|
||||
grid-template-columns: 34px auto;
|
||||
grid-template-columns: 34px auto auto;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
@@ -44,13 +44,17 @@
|
||||
}
|
||||
|
||||
.previewBadge {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
grid-row: 1;
|
||||
grid-column: 3;
|
||||
align-self: start;
|
||||
margin-top: 2px;
|
||||
margin-left: -3px;
|
||||
padding: 1px 7px 0;
|
||||
border: 1px solid var(--dsw-alias-interactive-bg-hover);
|
||||
border-radius: 24px;
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-bluish);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -37,7 +37,8 @@ export type InputBarProps = ComposerBarProps
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
|
||||
renderSlot, useNotices, useLexicon, useMenuLauncher,
|
||||
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
|
||||
useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder,
|
||||
accessory, overlay, leftItems, rightItems, footer,
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
@@ -86,8 +87,13 @@ export function InputBar({
|
||||
// inert no-workspace state, or the machine faces absent (no session). The
|
||||
// transient machine locks (adjudicating pending / submitting) render
|
||||
// read-only — the draft stays visible and focused, keystrokes drop.
|
||||
const disabled = removed || inert || !live
|
||||
const disabled = removed || inert || !live || blocked !== undefined
|
||||
const locked = disabled
|
||||
// The model seat is the ONE control a block leaves live: every block this
|
||||
// contract has is cleared by choosing a model, so locking it too would leave
|
||||
// the composer asking for the only thing it prevents. The other reasons to
|
||||
// be disabled do lock it — there is no session to choose a model for.
|
||||
const modelSeatLocked = removed || inert || !live
|
||||
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
|
||||
|
||||
// Scroll the draft scrollport the minimum that brings `caret` into view — the
|
||||
@@ -512,7 +518,7 @@ export function InputBar({
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
{rightItems}
|
||||
{renderSlot('conversation.input.model', { locked })}
|
||||
{renderSlot('conversation.input.model', { locked: modelSeatLocked })}
|
||||
<ContextMeter useProjection={useProjection} t={t} />
|
||||
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
|
||||
<Tooltip label={primaryLabel} side="top" delayMs={500}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
|
||||
async function bench() {
|
||||
@@ -23,6 +24,7 @@ async function bench() {
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
const fiber = runtime.ctx.plugin(ConversationService, {
|
||||
input: new InputHub(runtime.ctx),
|
||||
blocks: new ComposerBlockRegistry(),
|
||||
})
|
||||
await fiber.await()
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
@@ -86,6 +88,7 @@ describe('ConversationService', () => {
|
||||
const bare = new Context()
|
||||
await bare.plugin(ConversationService, {
|
||||
input: new InputHub(bare),
|
||||
blocks: new ComposerBlockRegistry(),
|
||||
}).await()
|
||||
const orphan = bare.get('conversation') as ConversationService
|
||||
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
|
||||
|
||||
@@ -91,6 +91,8 @@ function mount(
|
||||
omitSummaryRow?: boolean
|
||||
/** Classify the selected child as a subagent instead of an ordinary fork. */
|
||||
summaryOrigin?: 'subagent'
|
||||
/** A composer block another plugin raised for this session. */
|
||||
composerBlock?: { reason: string }
|
||||
} = {},
|
||||
) {
|
||||
const root = sid('root')
|
||||
@@ -118,9 +120,14 @@ function mount(
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const slotCalls: string[] = []
|
||||
/** Owner share handed to the two composer tool-row seats, per render. */
|
||||
const seatOwners: { key: string; owner: unknown }[] = []
|
||||
let pickerOwner: unknown
|
||||
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
|
||||
slotCalls.push(key)
|
||||
if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
|
||||
seatOwners.push({ key, owner })
|
||||
}
|
||||
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
|
||||
if (key === 'conversation.session.header') {
|
||||
return (
|
||||
@@ -198,7 +205,12 @@ function mount(
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
t={t}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
renderSlot={((key: string, seatOwner: object) => {
|
||||
// The bar's own seats: recorded so a case can assert what share
|
||||
// each tool-row control received.
|
||||
seatOwners.push({ key, owner: seatOwner })
|
||||
return null
|
||||
}) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
)
|
||||
@@ -224,6 +236,7 @@ function mount(
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useProjection: (() => undefined),
|
||||
useComposerBlock: select => select(options.composerBlock),
|
||||
useInput,
|
||||
inputActions,
|
||||
renderSlot,
|
||||
@@ -233,7 +246,7 @@ function mount(
|
||||
}
|
||||
const view = render(<ConversationRoot {...props} />)
|
||||
return {
|
||||
view, chat, sink, retargetWorkspace, session, slotCalls, open,
|
||||
view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
|
||||
pickerOwner: () => pickerOwner,
|
||||
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
|
||||
}
|
||||
@@ -242,12 +255,44 @@ function mount(
|
||||
describe('Hero chrome', () => {
|
||||
it('renders the English preview badge through the hero locale seat', () => {
|
||||
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
|
||||
expect(view.getByText('Let\'s start building')).toBeTruthy()
|
||||
expect(view.getByText('Into the Unknown')).toBeTruthy()
|
||||
expect(view.getByText('Preview')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot resident composer', () => {
|
||||
it('renders the composer inert with the blocker\u2019s own reason', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, {
|
||||
composerBlock: { reason: 'select a model first' },
|
||||
})
|
||||
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
// One disabled textarea with the blocker's placeholder, never a second
|
||||
// tree: the DOM survives the block being raised and cleared.
|
||||
expect(box.disabled).toBe(true)
|
||||
expect(box.placeholder).toBe('select a model first')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.sink).not.toHaveBeenCalled()
|
||||
|
||||
// The model seat stays live. Locking it too would leave the composer
|
||||
// asking for the one thing it prevents — every block this contract has is
|
||||
// cleared by choosing a model.
|
||||
const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner
|
||||
expect(seat('conversation.input.model')).toEqual({ locked: false })
|
||||
expect(seat('conversation.input.plan')).toEqual({ locked: true })
|
||||
})
|
||||
|
||||
it('lets the no-workspace posture win over a block', () => {
|
||||
// Picking a workspace is the earlier prerequisite; naming a model first
|
||||
// would send the user somewhere they cannot act yet.
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, {
|
||||
summaryBlank: true,
|
||||
composerBlock: { reason: 'select a model first' },
|
||||
})
|
||||
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(box.disabled).toBe(true)
|
||||
expect(box.placeholder).not.toBe('select a model first')
|
||||
})
|
||||
|
||||
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const box = b.view.getByRole('textbox')
|
||||
@@ -306,7 +351,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('预览版')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
@@ -330,7 +375,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('settling')
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
})
|
||||
|
||||
it('settling phase: a session the list has no row for settles conservatively', () => {
|
||||
@@ -355,7 +400,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
// blank the column for the history round-trip.
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('hero')
|
||||
expect(b.view.getByText('开始构建吧')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -373,7 +418,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText('开始构建吧')).toBeNull()
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -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-goal/README.md
|
||||
README.md: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde
|
||||
README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9
|
||||
README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0
|
||||
README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
|
||||
Indirectly, through the `goals/edit`, `goals/pause`, `goals/resume`, and `goals/clear` Remote methods the strip invokes: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
|
||||
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
|
||||
间接影响:条带通过调用 `goals/edit`、`goals/pause`、`goals/resume` 和 `goals/clear` Remote 方法提交变更;每次被接受的变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
@@ -36,8 +37,8 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-api-remotes": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
@@ -48,8 +49,8 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
* arrives through `useProjection('goal')` (seeded by the history tail page,
|
||||
* updated by session/projection frames), so this plugin owns no store, no
|
||||
* refresh chain, and no event listener. The inject face carries only the
|
||||
* three mutation verbs (edit/resume/clear over the goal.* wire domain);
|
||||
* four mutation verbs through the generated Goal Remote API;
|
||||
* their CAS ref reads the session's current projected value at call time.
|
||||
* Goal creation stays on the /goal host command.
|
||||
*/
|
||||
import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet).
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
|
||||
import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client'
|
||||
import type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
import { GoalDock } from './GoalBar.tsx'
|
||||
import { en, zh, type GoalKey } from './locales.ts'
|
||||
@@ -35,13 +35,32 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'goal'
|
||||
|
||||
/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs, locale for the copy. */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'locale']
|
||||
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
|
||||
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale']
|
||||
|
||||
/** Map one settled RPC result onto the strip's inline-render shape. */
|
||||
function settle<T>(result: RpcResult<T>): GoalActionResult {
|
||||
if (result.ok) return { ok: true }
|
||||
return { ok: false, error: { code: result.error.code, message: result.error.message } }
|
||||
/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */
|
||||
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
|
||||
try {
|
||||
await invoke()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const cause = error instanceof Error ? error.cause : undefined
|
||||
if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } }
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : 'goal mutation failed',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } {
|
||||
return value !== null
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as { code?: unknown }).code === 'string'
|
||||
&& typeof (value as { message?: unknown }).message === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,8 +70,6 @@ function settle<T>(result: RpcResult<T>): GoalActionResult {
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
|
||||
|
||||
const { goals } = (ctx.get('connection') as ConnectionHandle).api
|
||||
|
||||
const sessions = ctx.sessions
|
||||
|
||||
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
|
||||
@@ -77,22 +94,22 @@ export function apply(ctx: ClientContext): void {
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.edit({ sessionId, ref, objective })).result)
|
||||
return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective }))
|
||||
},
|
||||
onPause: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.pause({ sessionId, ref })).result)
|
||||
return settle(() => ctx.remote.goals.pause(sessionId, ref))
|
||||
},
|
||||
onResume: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.resume({ sessionId, ref })).result)
|
||||
return settle(() => ctx.remote.goals.resume(sessionId, ref))
|
||||
},
|
||||
onClear: async () => {
|
||||
const ref = refOf(sessionId)
|
||||
if (ref === undefined) return noCurrentGoal
|
||||
return settle((await goals.clear({ sessionId, ref })).result)
|
||||
return settle(() => ctx.remote.goals.clear(sessionId, ref))
|
||||
},
|
||||
}),
|
||||
}, GoalDock))
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ui-goal browser half on a real cordis Context with fake slots/connection/
|
||||
* ui-goal browser half on a real cordis Context with fake slots/api/
|
||||
* sessions faces: the plugin registers the GoalBar dock entry at
|
||||
* conversation.input.dock, the inject face's three verbs read the CAS ref
|
||||
* conversation.input.dock, the inject face's four verbs read the CAS ref
|
||||
* from the session's CURRENT projected value at call time (no fence — the
|
||||
* RPC's compare-and-set is the guard), a missing projection short-circuits
|
||||
* to the no-current-goal error without touching the wire, and RPC errors
|
||||
* Remote method's compare-and-set is the guard), a missing projection short-circuits
|
||||
* to the no-current-goal error without touching the wire, and Remote errors
|
||||
* map onto the inline-render result shape. Registration disposal rides the
|
||||
* plugin fiber (HMR safety). The node half and the invariant companion are
|
||||
* exercised over the same Context.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
@@ -44,27 +44,45 @@ function makeProjection(revision = 3): GoalProjection {
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */
|
||||
async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
|
||||
/** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */
|
||||
async function bench(options: {
|
||||
projection?: GoalProjection | null | undefined
|
||||
failWith?: { code: string; message: string }
|
||||
rejectWith?: unknown
|
||||
} = {}) {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; payload: unknown }[] = []
|
||||
const calls: { method: string; args: unknown[] }[] = []
|
||||
function answer<T>(method: string, value: T) {
|
||||
return (payload: unknown) => {
|
||||
calls.push({ method, payload })
|
||||
return Promise.resolve({
|
||||
result: options.failWith === undefined
|
||||
? { ok: true as const, value }
|
||||
: { ok: false as const, error: { ...options.failWith, details: {} } },
|
||||
})
|
||||
return (...args: unknown[]) => {
|
||||
calls.push({ method, args })
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test.
|
||||
if ('rejectWith' in options) return Promise.reject(options.rejectWith)
|
||||
if (options.failWith !== undefined) {
|
||||
return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith }))
|
||||
}
|
||||
return Promise.resolve(value)
|
||||
}
|
||||
}
|
||||
const ref = { id: 'g-1', revision: 3 }
|
||||
ctx.provide('connection', { api: { goals: {
|
||||
edit: answer('goal.edit', { ref }),
|
||||
pause: answer('goal.pause', { ref }),
|
||||
resume: answer('goal.resume', { ref }),
|
||||
clear: answer('goal.clear', { cleared: true as const }),
|
||||
} } })
|
||||
const goals = (prefix: string) => ({
|
||||
edit: answer(`${prefix}/edit`, { ref }),
|
||||
pause: answer(`${prefix}/pause`, { ref }),
|
||||
resume: answer(`${prefix}/resume`, { ref }),
|
||||
clear: answer(`${prefix}/clear`, ref),
|
||||
})
|
||||
let activeGoals: ReturnType<typeof goals> | undefined = goals('goals')
|
||||
class RemoteService extends Service {
|
||||
constructor(serviceCtx: Context) {
|
||||
super(serviceCtx, 'remote')
|
||||
}
|
||||
}
|
||||
new RemoteService(ctx)
|
||||
ctx.provide('remote.goals', {
|
||||
get edit() { return activeGoals?.edit },
|
||||
get pause() { return activeGoals?.pause },
|
||||
get resume() { return activeGoals?.resume },
|
||||
get clear() { return activeGoals?.clear },
|
||||
})
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
|
||||
@@ -85,6 +103,8 @@ async function bench(options: { projection?: GoalProjection | null | undefined;
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
remountGoals: () => { activeGoals = goals('remounted-goals') },
|
||||
unmountGoals: () => { activeGoals = undefined },
|
||||
entry: () => {
|
||||
const entry = ctx.slots.entries('conversation.input.dock')[0]
|
||||
if (entry === undefined) return undefined
|
||||
@@ -113,12 +133,34 @@ describe('ui-goal browser plugin', () => {
|
||||
expect(await verbs.onPause()).toEqual({ ok: true })
|
||||
expect(await verbs.onResume()).toEqual({ ok: true })
|
||||
expect(await verbs.onClear()).toEqual({ ok: true })
|
||||
expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear'])
|
||||
expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear'])
|
||||
const ref = { id: 'g-1', revision: 5 }
|
||||
expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' })
|
||||
expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref })
|
||||
expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref })
|
||||
expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref })
|
||||
expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }])
|
||||
expect(b.calls[1]?.args).toEqual(['s1', ref])
|
||||
expect(b.calls[2]?.args).toEqual(['s1', ref])
|
||||
expect(b.calls[3]?.args).toEqual(['s1', ref])
|
||||
})
|
||||
|
||||
it('verbs read a remounted Remote namespace at action time', async () => {
|
||||
const b = await bench({ projection: makeProjection() })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
b.remountGoals()
|
||||
|
||||
expect(await verbs.onPause()).toEqual({ ok: true })
|
||||
expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
|
||||
})
|
||||
|
||||
it('settles every verb when the Remote namespace is temporarily absent', async () => {
|
||||
const b = await bench({ projection: makeProjection() })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
b.unmountGoals()
|
||||
|
||||
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
}
|
||||
expect(b.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a null or absent projection short-circuits every verb without touching the wire', async () => {
|
||||
@@ -133,13 +175,26 @@ describe('ui-goal browser plugin', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('maps a settled RPC error onto the inline-render shape', async () => {
|
||||
it('maps a Remote error onto the inline-render shape', async () => {
|
||||
const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[new Error('connection closed'), 'connection closed'],
|
||||
['connection closed', 'goal mutation failed'],
|
||||
[new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'],
|
||||
[new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'],
|
||||
[new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'],
|
||||
])('maps an unstructured rejection onto an internal error', async (rejection, message) => {
|
||||
const b = await bench({ projection: makeProjection(), rejectWith: rejection })
|
||||
await b.fiber.await()
|
||||
const verbs = b.entry()!.inject!(sid('s1'))
|
||||
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } })
|
||||
})
|
||||
|
||||
it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
"path": "../../api/remotes"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
|
||||
@@ -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-layout/README.md
|
||||
README.md: 5cb8f01efb2e18109e917225dbce088ea77394af
|
||||
README.zh.md: 6559fe595a6219b139fe46cf046906fa63636f64
|
||||
README.md: fa60520a20ac8a7f25d494879c68efb06a28998f
|
||||
README.zh.md: 6ca04c56c29a55f84fc7a6399a7feeb81d249899
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens as inline variables on body, and one owned `<meta name="theme-color">` whose content follows the computed body background). Measuring after palette and token application keeps the rendered background as the single color authority; disposing the presenter removes its metadata node with its other global writes.
|
||||
|
||||
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量,同时拥有一个 `<meta name="theme-color">`,其内容随计算后的 body 背景色更新)。在应用调色板和 token 后进行测量,可确保渲染后的背景保持为唯一颜色真源;呈现器在资源释放时会移除其自有的元数据节点,并一并清除其写入的其他全局状态。
|
||||
|
||||
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Global theme DOM applier: projects the resolved ThemeSnapshot onto the
|
||||
* document — `html { color-scheme }` for native UA chrome (scrollbars, form
|
||||
* controls), `body[data-ds-dark-theme]` for the token palette, and the active
|
||||
* theme's alias-token overrides as inline CSS variables on body. Pure DOM
|
||||
* writes, no React involvement; the presenter only ever retracts what it wrote
|
||||
* itself, so foreign attributes and inline styles survive apply/dispose.
|
||||
* controls), `body[data-ds-dark-theme]` for the token palette, the active
|
||||
* theme's alias-token overrides as inline CSS variables on body, and one
|
||||
* presenter-owned `meta[name="theme-color"]` for surrounding browser UI. Pure
|
||||
* DOM writes, no React involvement; the presenter only ever retracts what it
|
||||
* wrote itself, so foreign attributes, metadata, and inline styles survive.
|
||||
*/
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
@@ -15,12 +16,22 @@ export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
||||
export class ThemePresenter {
|
||||
/** Token names this presenter wrote in the last apply (its retraction set). */
|
||||
private appliedTokens: string[] = []
|
||||
/** The single metadata node this presenter inserts and removes. */
|
||||
private readonly themeColorMeta: HTMLMetaElement
|
||||
|
||||
/** Create the presenter-owned metadata node before the first snapshot arrives. */
|
||||
constructor() {
|
||||
this.themeColorMeta = document.createElement('meta')
|
||||
this.themeColorMeta.name = 'theme-color'
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a snapshot onto the document: set root `color-scheme` and the body
|
||||
* palette attribute from `active.colorScheme` (never the id — `system` is
|
||||
* resolved upstream), then replace the previously applied token variables
|
||||
* with `active.tokens`.
|
||||
* with `active.tokens`. Browser theme-color metadata follows the computed
|
||||
* body background after those writes, so the rendered palette remains the
|
||||
* color authority.
|
||||
* @param snapshot - resolved theme snapshot from ctx.theme.
|
||||
*/
|
||||
apply(snapshot: ThemeSnapshot): void {
|
||||
@@ -35,14 +46,17 @@ export class ThemePresenter {
|
||||
body.style.setProperty(name, value)
|
||||
this.appliedTokens.push(name)
|
||||
}
|
||||
this.themeColorMeta.content = getComputedStyle(body).backgroundColor
|
||||
if (!this.themeColorMeta.isConnected) document.head.append(this.themeColorMeta)
|
||||
}
|
||||
|
||||
/** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */
|
||||
/** Retract root color-scheme, the palette attribute, token variables, and the owned metadata node. */
|
||||
dispose(): void {
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
const body = document.body
|
||||
body.removeAttribute(DARK_ATTRIBUTE)
|
||||
for (const name of this.appliedTokens) body.style.removeProperty(name)
|
||||
this.appliedTokens = []
|
||||
this.themeColorMeta.remove()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// coverage gate still requires exercised.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
@@ -15,6 +15,10 @@ import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
|
||||
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
|
||||
|
||||
beforeEach(() => {
|
||||
document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() })
|
||||
})
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
@@ -65,13 +69,17 @@ describe('ui-layout client apply', () => {
|
||||
// Initial getter application: jsdom has no matchMedia, system resolves light.
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
|
||||
const themeColorMeta = document.head.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
||||
expect(themeColorMeta).not.toBeNull()
|
||||
const theme = ctx.get('theme') as ThemeService
|
||||
theme.setTheme('dark')
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
|
||||
expect(document.head.querySelector('meta[name="theme-color"]')).toBe(themeColorMeta)
|
||||
await fiber.dispose()
|
||||
expect(document.documentElement.style.colorScheme).toBe('')
|
||||
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
|
||||
expect(themeColorMeta?.isConnected).toBe(false)
|
||||
// Listener is off: further theme changes no longer reach the document.
|
||||
theme.setTheme('light')
|
||||
theme.setTheme('dark')
|
||||
|
||||
@@ -1,40 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
// ThemePresenter behavior account: root color-scheme and the palette attribute
|
||||
// follow active.colorScheme only, token variables replace the previous apply's
|
||||
// set, and dispose retracts everything the presenter wrote.
|
||||
// set, theme-color metadata follows the rendered body background, and dispose
|
||||
// retracts everything the presenter wrote.
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts'
|
||||
|
||||
const LIGHT_THEME_COLOR = 'rgb(255, 255, 255)'
|
||||
const DARK_THEME_COLOR = 'rgb(21, 21, 23)'
|
||||
|
||||
function snapshot(colorScheme: 'light' | 'dark', tokens: Record<string, string> = {}): ThemeSnapshot {
|
||||
// The presenter must key off colorScheme, not the id — keep them distinct.
|
||||
const active = { id: `${colorScheme}-test`, colorScheme, tokens }
|
||||
return { preference: colorScheme, active, themes: [active], revision: 1 }
|
||||
}
|
||||
|
||||
function clearThemePresentation(): void {
|
||||
document.head.querySelectorAll('meta[name="theme-color"], style[data-theme-presenter-test]').forEach((node) => { node.remove() })
|
||||
}
|
||||
|
||||
function themeColorMeta(): HTMLMetaElement | null {
|
||||
return document.head.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearThemePresentation()
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
document.body.removeAttribute(DARK_ATTRIBUTE)
|
||||
document.body.removeAttribute('style')
|
||||
const style = document.createElement('style')
|
||||
style.dataset.themePresenterTest = ''
|
||||
style.textContent = `
|
||||
body { background-color: ${LIGHT_THEME_COLOR}; }
|
||||
body[${DARK_ATTRIBUTE}] { background-color: ${DARK_THEME_COLOR}; }
|
||||
`
|
||||
document.head.append(style)
|
||||
})
|
||||
|
||||
afterEach(clearThemePresentation)
|
||||
|
||||
describe('ThemePresenter', () => {
|
||||
it('light scheme sets root color-scheme and leaves the dark attribute absent', () => {
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('light'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
expect(themeColorMeta()?.content).toBe(LIGHT_THEME_COLOR)
|
||||
})
|
||||
|
||||
it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => {
|
||||
it('dark scheme sets root color-scheme, the attribute, and metadata; switching to light updates one node', () => {
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('dark'))
|
||||
const meta = themeColorMeta()
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
|
||||
expect(meta?.content).toBe(DARK_THEME_COLOR)
|
||||
presenter.apply(snapshot('light'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
expect(themeColorMeta()).toBe(meta)
|
||||
expect(meta?.content).toBe(LIGHT_THEME_COLOR)
|
||||
expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('applies tokens as inline variables and clears the previous set on theme change', () => {
|
||||
@@ -52,10 +80,12 @@ describe('ThemePresenter', () => {
|
||||
document.body.style.setProperty('--foreign', 'kept')
|
||||
const presenter = new ThemePresenter()
|
||||
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' }))
|
||||
const meta = themeColorMeta()
|
||||
presenter.dispose()
|
||||
expect(document.documentElement.style.colorScheme).toBe('')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('')
|
||||
expect(document.body.style.getPropertyValue('--foreign')).toBe('kept')
|
||||
expect(meta?.isConnected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-model/README.md
|
||||
README.md: 5f9fc65939eb747d916fa5609423d3186d1fefde
|
||||
README.zh.md: 3ed8db3095d96e48813cf5b15a206ebf4c894950
|
||||
README.md: 5a6f998476629566d35af32efa5d8bc5072a872b
|
||||
README.zh.md: 2bb22c55f1ae5af59f21e254804329d288806a90
|
||||
|
||||
@@ -6,6 +6,8 @@ Model selection plugin, browser half: TWO entries over ONE per-session directory
|
||||
|
||||
The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory.
|
||||
|
||||
When the Host reports that no adapter serves the session's route (`session.models.routable`), this plugin raises a composer block through `ctx.conversation.blocks` and the input goes inert with this plugin's own copy; recovering clears it without a reload. It follows `routable` and nothing else: a `null` — before the first load, or after one failed — never blocks, or a slow Host would lock a working composer, and catalog membership never blocks either, because a route serving a model it stopped advertising is missing from the groups yet perfectly usable. The trigger's own `Select model` fallback still covers that case, which is display, not a gate.
|
||||
|
||||
Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。
|
||||
|
||||
当宿主报告没有适配器服务该会话的路由(`session.models.routable`)时,本插件经 `ctx.conversation.blocks` 抬起一个编辑器 block,输入框随之变为惰性并显示本插件自己的文案;恢复后无需重新加载即自动清除。它只跟随 `routable`:`null`(首次加载之前,或加载失败之后)绝不阻断,否则一个慢的宿主就会锁死一个本来能用的编辑器;目录成员关系同样不阻断,因为一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用。触发器自己的 `Select model` 回退仍然覆盖那种情形——那是显示,不是闸门。
|
||||
|
||||
目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、slot 注入面类型。
|
||||
|
||||
@@ -15,6 +15,14 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
export interface ModelDirectoryState {
|
||||
/** Target the host reports for the next assembled step; null before the first load. */
|
||||
current: ModelTarget | null
|
||||
/**
|
||||
* Whether an adapter serves the current target's route, as the host reports
|
||||
* it — null before the first load, which is NOT the same as blocked. Read
|
||||
* this rather than "current matches no group": catalog membership is
|
||||
* advisory, so a route serving a model it stopped advertising is missing
|
||||
* from the groups yet perfectly usable.
|
||||
*/
|
||||
routable: boolean | null
|
||||
/** Successfully loaded provider groups (last good load). */
|
||||
groups: readonly ModelProviderGroup[]
|
||||
/** Provider-local failures from the last load; usable groups stay usable. */
|
||||
@@ -29,7 +37,7 @@ export interface ModelDirectoryState {
|
||||
export class ModelDirectory {
|
||||
/** The shared snapshot both entries render from (uSES-safe store). */
|
||||
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
|
||||
current: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
current: null, routable: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
})
|
||||
|
||||
/** Latest operation wins; an older response never overwrites a newer one. */
|
||||
@@ -65,9 +73,10 @@ export class ModelDirectory {
|
||||
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
|
||||
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
const { current, groups, failures } = result.value
|
||||
const { current, routable, groups, failures } = result.value
|
||||
this.store.update((s) => {
|
||||
s.current = current
|
||||
s.routable = routable
|
||||
s.groups = groups
|
||||
s.failures = failures
|
||||
s.status = 'ready'
|
||||
@@ -102,7 +111,14 @@ export class ModelDirectory {
|
||||
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
|
||||
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null })
|
||||
// The Host validated the route before accepting it, so a selection that
|
||||
// landed is by construction one it can serve.
|
||||
this.store.update((s) => {
|
||||
s.current = result.value.selected
|
||||
s.routable = true
|
||||
s.status = 'ready'
|
||||
s.error = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +131,7 @@ export class ModelDirectory {
|
||||
++this.generation
|
||||
this.store.update((s) => {
|
||||
s.current = null
|
||||
s.routable = null
|
||||
s.groups = []
|
||||
s.failures = []
|
||||
s.status = 'idle'
|
||||
|
||||
@@ -105,14 +105,16 @@ export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(ModelService)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
|
||||
|
||||
// Non-slot faces (the command description, the popup option builder) read
|
||||
// through the bound translate; the seat component reads the standard seat.
|
||||
const t = ctx.locale.bind(NS)
|
||||
|
||||
// The composer-block reason is this plugin's own copy, read at raise time so
|
||||
// a locale change reaches the next publish.
|
||||
ctx.plugin(ModelService, { blockReason: () => t('blocked.composer') })
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory. The command
|
||||
// description is registry-held text: it reads t() once at registration and
|
||||
// refreshes only on re-registration, not on locale change.
|
||||
|
||||
@@ -25,6 +25,7 @@ export const zh = {
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'blocked.composer': '当前模型不可用,请先选择模型',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -48,5 +49,6 @@ export const en = {
|
||||
'action.reload': 'Reload',
|
||||
'warning.groupLoad': '{name} failed to load: {message}',
|
||||
'empty.models': 'No models available.',
|
||||
'blocked.composer': 'This model is unavailable — select one to continue',
|
||||
'empty.efforts': 'This model provides no reasoning effort levels.',
|
||||
} satisfies Record<ModelKey, string>
|
||||
|
||||
@@ -36,11 +36,16 @@ export class ModelService extends Service {
|
||||
|
||||
private readonly live: LiveState = { directories: new Map() }
|
||||
|
||||
/** Localized composer-block copy; this plugin owns the string it raises. */
|
||||
private readonly blockReason: () => string
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the service registers itself as `models`).
|
||||
* @param config - the bound translator for this plugin's own dictionary.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, config: { blockReason: () => string }) {
|
||||
super(ctx, 'models')
|
||||
this.blockReason = config.blockReason
|
||||
ctx.on('connection/reset', () => {
|
||||
for (const directory of this.live.directories.values()) directory.resetConnected()
|
||||
})
|
||||
@@ -74,6 +79,27 @@ export class ModelService extends Service {
|
||||
() => sessions.subagentAddress(sessionId) === undefined,
|
||||
)
|
||||
live.directories.set(sessionId, directory)
|
||||
// The composer cannot read this plugin (the dependency runs one way), so
|
||||
// the block is pushed: the Host says whether an adapter serves the
|
||||
// session's route, and only a definite `false` makes the input inert.
|
||||
// `null` — before the first load, or after one failed — must not, or a
|
||||
// slow or unreachable Host would lock a working composer.
|
||||
const conversation = this.ctx.get('conversation')
|
||||
if (conversation !== undefined) {
|
||||
const publish = (): void => {
|
||||
conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false
|
||||
? { reason: this.blockReason() }
|
||||
: undefined)
|
||||
}
|
||||
publish()
|
||||
actx.effect(() => {
|
||||
const stop = directory.store.subscribe(publish)
|
||||
return () => {
|
||||
stop()
|
||||
conversation.blocks.set(sessionId, undefined)
|
||||
}
|
||||
}, 'ui-model: composer block')
|
||||
}
|
||||
actx.effect(() => () => {
|
||||
directory.dispose()
|
||||
live.directories.delete(sessionId)
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
@@ -59,7 +60,9 @@ async function bench() {
|
||||
ctx.provide('connection', { api: { sessions: {
|
||||
models: () => {
|
||||
calls.models += 1
|
||||
return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } })
|
||||
return Promise.resolve({
|
||||
result: { ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } },
|
||||
})
|
||||
},
|
||||
selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => {
|
||||
calls.select += 1
|
||||
@@ -73,6 +76,15 @@ async function bench() {
|
||||
return Promise.resolve({ result: { ok: true as const, value: { selected: current } } })
|
||||
},
|
||||
} } })
|
||||
// Whether the Host reports an adapter for the current route; the composer
|
||||
// block follows this, never catalog membership.
|
||||
let routable = true
|
||||
const blocks = new Map<SessionId, { reason: string } | undefined>()
|
||||
ctx.provide('conversation', {
|
||||
blocks: {
|
||||
set: (id: SessionId, block: { reason: string } | undefined) => { blocks.set(id, block) },
|
||||
},
|
||||
})
|
||||
let contribution: CommandContribution | undefined
|
||||
ctx.provide('command', {
|
||||
register(c: CommandContribution) {
|
||||
@@ -115,6 +127,8 @@ async function bench() {
|
||||
hostCurrent: () => current,
|
||||
setHostCurrent: (target: ModelTarget) => { current = target },
|
||||
address: (id: SessionId) => { addressed.add(id) },
|
||||
setRoutable: (next: boolean) => { routable = next },
|
||||
blockOf: (key: string) => blocks.get(sid(key)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +231,63 @@ describe('ui-model dual entry', () => {
|
||||
expect(face2.directory).not.toBe(face1.directory)
|
||||
})
|
||||
|
||||
it('blocks the composer only once the Host reports the route unservable', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
|
||||
// Before the first load nothing is known. `null` is not `false`: a slow
|
||||
// or unreachable Host must never lock a working composer.
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
|
||||
b.setRoutable(false)
|
||||
b.ctx.emit('models/changed')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
|
||||
|
||||
// Recovering clears it without a reload of the surface.
|
||||
b.setRoutable(true)
|
||||
b.ctx.emit('models/changed')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never blocks on catalog membership alone', async () => {
|
||||
const b = await bench()
|
||||
b.mint('s1')
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
// A model the route serves but no longer advertises: the seat prompts for
|
||||
// a selection, the composer stays usable. Blocking here would break a
|
||||
// supported configuration (a narrowed `models` list over a live route).
|
||||
b.setHostCurrent({ provider: 'deepseek-official', model: 'unlisted' })
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const snapshot = face.directory.getSnapshot()
|
||||
expect(snapshot.groups.flatMap(group => group.models.map(model => model.id))).not.toContain('unlisted')
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears its block when the session scope goes', async () => {
|
||||
const b = await bench()
|
||||
const scope = b.mint('s1')
|
||||
b.setRoutable(false)
|
||||
const face = b.seat().inject!(sid('s1'))
|
||||
face.load()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(b.blockOf('s1')).toBeDefined()
|
||||
|
||||
await scope.fiber.dispose()
|
||||
expect(b.blockOf('s1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an unknown session fails loud at the seat inject', async () => {
|
||||
const b = await bench()
|
||||
expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
|
||||
@@ -32,6 +32,7 @@ const reasoning = {
|
||||
function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
|
||||
return {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
|
||||
@@ -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: 80ae642ec9d6f91c78af041dda0b201959309577
|
||||
README.zh.md: 4236c8fec4f6d5e51363095d790944af9c08092a
|
||||
README.md: 66f23d3f77adae23fcebd3adbb6c5c47ce16ae2f
|
||||
README.zh.md: dcb35c624aca73b558532210750f684e5cc54e85
|
||||
|
||||
@@ -4,11 +4,11 @@ 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 literal key or 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), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; 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.
|
||||
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) and each adapter's model catalog. 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 took the whole provider out of the model picker. 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`; 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 literal `apiKey` secret sidecar or 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 `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.
|
||||
|
||||
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 names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. 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, the same shape 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 shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic 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. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
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, the same shape 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 shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic 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. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
@@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing
|
||||
|
||||
**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand.
|
||||
|
||||
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts.
|
||||
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 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 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。
|
||||
行是*已配置*的提供方(其 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 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。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;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `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;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
@@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
|
||||
|
||||
**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。
|
||||
|
||||
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。
|
||||
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
* The three fields a hand-declared route cannot default — endpoint, protocol,
|
||||
* and at least one model — are required here rather than at load, so the
|
||||
* failure names the field while the user is still looking at it.
|
||||
*
|
||||
* There is deliberately no reasoning-effort control, here or on the editor
|
||||
* card: effort is a per-MODEL capability, and the models under one provider
|
||||
* disagree about it, so a provider-scoped control can only be set to a value
|
||||
* some of them reject. The composer's model picker offers each model its own
|
||||
* levels instead.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -30,8 +36,15 @@ import styles from './ModelsSection.module.css'
|
||||
/** The settings namespace a hand-declared provider is written into. */
|
||||
const NS = 'llm-pi-ai'
|
||||
|
||||
/** A route id usable as a settings key and as the stem of a credential name. */
|
||||
const ROUTE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
/**
|
||||
* A route id usable as a settings key AND as the stem of a credential name.
|
||||
* The leading letter is the second half of that: `deriveKeyRef` uppercases the
|
||||
* id and replaces every non-alphanumeric run with `_`, and a credential
|
||||
* reference is a POSIX shell identifier, which cannot start with a digit. A
|
||||
* digit-leading id passes every check this card makes and then fails at the
|
||||
* credential seam with a raw regular expression the user cannot act on.
|
||||
*/
|
||||
const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
||||
|
||||
/** Props of {@link CustomProviderCard}. */
|
||||
export interface CustomProviderCardProps {
|
||||
@@ -73,7 +86,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
const [models, setModels] = useState<readonly ModelDraft[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
/**
|
||||
* The profile write landed. Only the key write can still be outstanding, so
|
||||
* the fields that describe the provider are settled and the retry path is
|
||||
* the credential alone.
|
||||
*/
|
||||
const [committed, setCommitted] = useState(false)
|
||||
const disabled = props.readOnly || busy
|
||||
/** Everything but the key stops being editable once the provider exists. */
|
||||
const profileDisabled = disabled || committed
|
||||
|
||||
const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
|
||||
const routeTaken = taken.includes(route)
|
||||
@@ -89,14 +110,17 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
const ready = route.length > 0 && !routeInvalid && !routeTaken
|
||||
&& baseURL.length > 0 && models.length > 0 && modelFailure === undefined
|
||||
&& keyFailure === undefined
|
||||
// The one blocked gate worth a line under the form. The route id is omitted
|
||||
// because its own field already explains itself, and a satisfied card says
|
||||
// The one blocked gate worth a line under the form. A satisfied card says
|
||||
// nothing at all rather than printing an empty paragraph.
|
||||
const hint = failure !== undefined || ready
|
||||
// The key field prints its own failure directly beneath itself, so a card
|
||||
// blocked only by the key stays silent here rather than answering with the
|
||||
// next unmet gate — which is satisfied, and reads as a second, false fault.
|
||||
|| keyFailure !== undefined
|
||||
// Same for the route id, and it must be tested rather than assumed: the
|
||||
// fallback arm below reads "no models yet", so an unmet route gate used to
|
||||
// fall through to it and contradict the filled-in list right above.
|
||||
|| route.length === 0 || routeInvalid || routeTaken
|
||||
? undefined
|
||||
: baseURL.length === 0
|
||||
? t('customNeedsBaseUrl')
|
||||
@@ -107,26 +131,38 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
/** Perform the create, returning a failure message or undefined. */
|
||||
const createOnce = async (): Promise<string | undefined> => {
|
||||
const keyRef = deriveKeyRef(route)
|
||||
const profile = {
|
||||
...displayName.length === 0 ? {} : { displayName },
|
||||
apiKeyEnv: keyRef,
|
||||
api: protocol,
|
||||
baseURL,
|
||||
models: models.map(model => ({ ...model })),
|
||||
const storesKey = keyValue.length > 0
|
||||
if (!committed) {
|
||||
const profile = {
|
||||
...displayName.length === 0 ? {} : { displayName },
|
||||
// The profile names the conventional reference only when this card is
|
||||
// about to store a key, matching the editor: a route declared with the
|
||||
// key left blank keeps its provider-native auth path (a credential
|
||||
// chain, ADC) instead of resolving a reference nothing ever sets.
|
||||
...storesKey ? { apiKeyEnv: keyRef } : {},
|
||||
api: protocol,
|
||||
baseURL,
|
||||
models: models.map(model => ({ ...model })),
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
ns: NS,
|
||||
ops: [{ op: 'set', path: ['providers', route], value: profile }],
|
||||
// `taken` is a snapshot too, so the id check alone cannot see a route
|
||||
// declared after this card opened; the revision makes that race a
|
||||
// `settings-conflict` instead of a write over the other profile.
|
||||
expectedRevision: openedAt,
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
// The provider now exists. A retry after the key write below fails must
|
||||
// not re-run this mutate: the revision it holds is the one this write
|
||||
// just superseded, so the Host would answer `settings-conflict` and the
|
||||
// key could never be stored from this card at all.
|
||||
setCommitted(true)
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
ns: NS,
|
||||
ops: [{ op: 'set', path: ['providers', route], value: profile }],
|
||||
// `taken` is a snapshot too, so the id check alone cannot see a route
|
||||
// declared after this card opened; the revision makes that race a
|
||||
// `settings-conflict` instead of a write over the other profile.
|
||||
expectedRevision: openedAt,
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
if (keyValue.length > 0) {
|
||||
if (storesKey) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
|
||||
// The profile landed; saying the key did not is the only honest report,
|
||||
// and the row is now editable so the key can be entered again there.
|
||||
// and the retry above now goes straight back to this write.
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
return undefined
|
||||
@@ -164,13 +200,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
value={route}
|
||||
placeholder="acme-gateway"
|
||||
aria-label={t('customRoute')}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setRoute(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<p className={styles['advancedHint']}>
|
||||
{routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')}
|
||||
</p>
|
||||
{/* A rejected id reads as a fault, not as guidance — the same split the
|
||||
key field below already makes between its failure and its hint. */}
|
||||
{routeInvalid || routeTaken
|
||||
? <p className={styles['error']}>{t(routeInvalid ? 'customRouteInvalid' : 'customRouteTaken')}</p>
|
||||
: <p className={styles['advancedHint']}>{t('customRouteHint')}</p>}
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
|
||||
<input
|
||||
@@ -179,7 +217,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
value={displayName}
|
||||
placeholder={route.length === 0 ? t('customDisplayName') : route}
|
||||
aria-label={t('customDisplayName')}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setDisplayName(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
@@ -191,7 +229,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
value={baseURL}
|
||||
placeholder="https://gateway.example/v1"
|
||||
aria-label={t('baseUrl')}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setBaseURL(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
@@ -201,7 +239,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
className={styles['input']}
|
||||
value={protocol}
|
||||
aria-label={t('customApi')}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
onChange={(event) => { setProtocol(event.target.value) }}
|
||||
>
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
||||
@@ -238,7 +276,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
|
||||
api={api}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
disabled={profileDisabled}
|
||||
/>
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
{/* Only the gates with something to say render; the route-id gate has its
|
||||
@@ -250,7 +288,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
submitDisabled={disabled || !ready}
|
||||
submitLabel="create"
|
||||
submitBusyLabel="creating"
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
onCancel={() => { props.onClose(committed) }}
|
||||
onSubmit={() => { void create() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +86,20 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Reads as an annotation on the name, not as a second name: caption size and
|
||||
the secondary label tone, so it never competes with the row's own title. It
|
||||
sits inside `rowIdentity` with the credential dot, which is what keeps it
|
||||
beside the name rather than drifting toward the actions. */
|
||||
.rowTag {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--dsw-alias-border-l3);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.credentialDot {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
|
||||
@@ -80,8 +80,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
|
||||
* Remove one user-added provider and its page-managed credential. Credential
|
||||
* removal comes first so a second-step failure leaves the provider row visible
|
||||
* and the whole operation safely retryable; both unsets are idempotent.
|
||||
* The settings removal names the profile rather than rebuilding its redacted
|
||||
* namespace, which would drop literal secrets stored elsewhere.
|
||||
* The settings removal names the profile rather than rebuilding its whole
|
||||
* namespace from a partial view.
|
||||
* @param api - settings and credential wire faces.
|
||||
* @param controller - the page store to refresh.
|
||||
* @param target - the provider's settings address and optional managed credential.
|
||||
@@ -112,16 +112,14 @@ export async function removeProviderProfile(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a whole-section provider still needs its first key: nothing marks
|
||||
* the credential configured and no literal `apiKey` is stored, so the page
|
||||
* opens the setup card instead of showing a row.
|
||||
* Whether a whole-section provider still needs its first key: an unconfigured
|
||||
* credential opens the setup card instead of showing a row.
|
||||
* @param row - the joined provider row.
|
||||
* @returns whether to render the setup card.
|
||||
*/
|
||||
export function needsSetup(row: ProviderRow): boolean {
|
||||
if (row.entry.settingsPath.length > 0) return false
|
||||
if (row.credential?.configured === true) return false
|
||||
return !row.literalApiKeyConfigured
|
||||
return row.credential?.configured !== true
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
@@ -264,7 +262,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
)
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
|
||||
const credentialConfigured = row.credential?.configured === true
|
||||
const credentialMissing = !credentialConfigured
|
||||
&& row.apiKeyEnv !== undefined
|
||||
&& row.credential?.configured === false
|
||||
@@ -273,6 +271,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<div className={styles['rowHead']}>
|
||||
<span className={styles['rowIdentity']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
{/* Only the adapter can tell a hand-declared route from a
|
||||
shipped one it also has a stored profile for, so the tag
|
||||
follows its answer and stays off when it gives none. */}
|
||||
{row.entry.declared === true
|
||||
? <span className={styles['rowTag']}>{t('customTag')}</span>
|
||||
: null}
|
||||
{credentialConfigured
|
||||
? (
|
||||
<span
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
* a key is entered; a blank key materializes a reference-free profile for
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
|
||||
* DeepSeek's id/name/context-window model catalog). Everything else stays
|
||||
* both families and DeepSeek's id/name/context-window model catalog).
|
||||
* Reasoning effort is deliberately absent: it is a per-MODEL capability, and
|
||||
* the models under one provider disagree about it, so a provider-scoped
|
||||
* control can only be set to a value some of them reject. The composer's
|
||||
* model picker offers each model its own levels; `settings.yaml` keeps the
|
||||
* profile field for a deployment that knows its route. Everything else stays
|
||||
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
|
||||
* path ops against the stored section — the card reads the redacted
|
||||
* descriptor, so it names only the fields it can see and a stored literal
|
||||
* secret is never collaterally removed.
|
||||
* path ops against the stored section — the card names only the fields it can
|
||||
* see instead of rebuilding the whole subtree from a partial descriptor.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -34,18 +37,6 @@ import styles from './ModelsSection.module.css'
|
||||
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
|
||||
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
|
||||
|
||||
/** Reasoning vocabularies per layout; the empty option means "inherit". */
|
||||
const EFFORT_CHOICES: Record<'deepseek' | 'pi-ai', readonly string[]> = {
|
||||
deepseek: ['off', 'high', 'max'],
|
||||
'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
|
||||
}
|
||||
|
||||
/** The draft key the effort select edits, per layout. */
|
||||
const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = {
|
||||
deepseek: 'reasoningEffort',
|
||||
'pi-ai': 'reasoning',
|
||||
}
|
||||
|
||||
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
|
||||
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
@@ -80,10 +71,9 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
|
||||
|
||||
/**
|
||||
* The minimal path ops carrying `after` over `before`, both as the card sees
|
||||
* them (that is, redacted). Only keys the card observed are named: a stored
|
||||
* `role('secret')` field appears in neither side, so it produces no op and
|
||||
* survives the write — the whole reason edits are path-addressed rather than
|
||||
* a rebuilt section.
|
||||
* them. Only keys the card observed are named; fields absent from both sides
|
||||
* produce no op, which is why edits are path-addressed rather than a rebuilt
|
||||
* section.
|
||||
* @param base - path of the edited subtree inside the user section.
|
||||
* @param before - the subtree as loaded, or undefined when it is new.
|
||||
* @param after - the subtree as edited.
|
||||
@@ -205,9 +195,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
/**
|
||||
* The write for this card, or a failure message. Every edit travels as
|
||||
* path ops against the STORED section: the draft comes from the redacted
|
||||
* descriptor, so a wholesale replace rebuilt from it would delete the
|
||||
* literal secrets the wire never returned. Ops name only the fields this
|
||||
* card can see, so a stored secret is untouched by construction.
|
||||
* descriptor, so a wholesale replace rebuilt from it could delete fields
|
||||
* outside the card. Ops name only the fields this card can see.
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
@@ -305,7 +294,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
* unknown namespace never reaches this body.
|
||||
*/
|
||||
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
|
||||
const effortField = EFFORT_FIELD[family]
|
||||
const customModels = getPath(draft, ['models'])
|
||||
const modelsOverridden = hasPath(draft, ['models'])
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
@@ -362,23 +350,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('effort')}</span>
|
||||
<select
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={stringAt(draft, effortField) ?? ''}
|
||||
aria-label={t('effort')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setField(effortField, event.target.value === '' ? undefined : event.target.value)
|
||||
}}
|
||||
>
|
||||
<option value="">{t('effortInherit')}</option>
|
||||
{EFFORT_CHOICES[family].map(choice => (
|
||||
<option key={choice} value={choice}>{choice}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Both families edit the same rows through the same contract; only
|
||||
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
||||
endpoint interrogation. */}
|
||||
|
||||
@@ -35,8 +35,6 @@ export const en = {
|
||||
customized: 'Customized settings',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlDefault: 'Provider default',
|
||||
effort: 'Reasoning effort',
|
||||
effortInherit: 'Default',
|
||||
models: 'Models',
|
||||
modelsInherited: 'Using the adapter defaults',
|
||||
modelsCustomized: 'Customized model catalog',
|
||||
@@ -75,9 +73,10 @@ export const en = {
|
||||
fetchAdopt: 'Add selected',
|
||||
customAdd: 'Add a custom provider',
|
||||
customTitle: 'Custom provider',
|
||||
customTag: 'Custom',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.',
|
||||
customRouteInvalid: 'Use lowercase letters, digits, and dashes.',
|
||||
customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.',
|
||||
customRouteInvalid: 'Start with a lowercase letter; then lowercase letters, digits, and dashes.',
|
||||
customRouteTaken: 'A provider already uses this ID.',
|
||||
customDisplayName: 'Display name',
|
||||
customApi: 'API protocol',
|
||||
@@ -129,8 +128,6 @@ export const zh: typeof en = {
|
||||
customized: '自定义设置',
|
||||
baseUrl: 'API 地址',
|
||||
baseUrlDefault: '提供方默认',
|
||||
effort: '推理强度',
|
||||
effortInherit: '默认',
|
||||
models: '模型目录',
|
||||
modelsInherited: '正在使用适配器默认模型',
|
||||
modelsCustomized: '已自定义模型目录',
|
||||
@@ -169,9 +166,10 @@ export const zh: typeof en = {
|
||||
fetchAdopt: '添加所选',
|
||||
customAdd: '添加自定义提供方',
|
||||
customTitle: '自定义提供方',
|
||||
customTag: '自定义',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。',
|
||||
customRouteInvalid: '只能使用小写字母、数字和短横线。',
|
||||
customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。',
|
||||
customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。',
|
||||
customRouteTaken: '已有提供方使用了这个 ID。',
|
||||
customDisplayName: '显示名称',
|
||||
customApi: 'API 协议',
|
||||
|
||||
@@ -31,8 +31,6 @@ export interface ProviderRow {
|
||||
apiKeyEnv: string | undefined
|
||||
/** Credential state for {@link apiKeyEnv}, once described. */
|
||||
credential: CredentialView | undefined
|
||||
/** Whether the redacted secret sidecar reports an effective literal `apiKey`. */
|
||||
literalApiKeyConfigured: boolean
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
@@ -97,19 +95,6 @@ function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonl
|
||||
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
|
||||
}
|
||||
|
||||
/** Whether one namespace's redacted sidecar reports a set literal API key. */
|
||||
function literalApiKeyConfigured(
|
||||
namespace: SettingsNamespaceView | undefined,
|
||||
path: readonly string[],
|
||||
): boolean {
|
||||
if (namespace === undefined) return false
|
||||
const secretPath = [...path, 'apiKey']
|
||||
return namespace.secrets.some(secret =>
|
||||
secret.set
|
||||
&& secret.path.length === secretPath.length
|
||||
&& secret.path.every((key, index) => key === secretPath[index]))
|
||||
}
|
||||
|
||||
/** The models settings page controller (one per settings surface). */
|
||||
export class ModelsSettingsStore {
|
||||
/** The snapshot the section renders from (uSES-safe store). */
|
||||
@@ -170,7 +155,6 @@ export class ModelsSettingsStore {
|
||||
removable,
|
||||
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
|
||||
credential: undefined,
|
||||
literalApiKeyConfigured: literalApiKeyConfigured(namespace, entry.settingsPath),
|
||||
}
|
||||
})
|
||||
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
|
||||
@@ -257,7 +241,6 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
|
||||
reason: 'settings-unavailable',
|
||||
}
|
||||
}
|
||||
if (row.literalApiKeyConfigured) return { kind: 'configured' }
|
||||
if (row.apiKeyEnv === undefined) {
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
|
||||
@@ -35,9 +35,7 @@ function capacityInputs(label: string): HTMLInputElement[] {
|
||||
}
|
||||
|
||||
const PiAiConfig = Schema.object({
|
||||
token: Schema.string().role('secret'),
|
||||
providers: Schema.dict(Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string(),
|
||||
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
@@ -46,7 +44,6 @@ const PiAiConfig = Schema.object({
|
||||
})
|
||||
|
||||
const DeepSeekConfig = Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string().pattern(/^https:\/\//),
|
||||
reasoningEffort: Schema.union(['off', 'high', 'max']),
|
||||
@@ -92,15 +89,14 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
value: {
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
baseURL: 'https://base',
|
||||
reasoningEffort: 'high',
|
||||
defaultContextWindow: 1_000_000,
|
||||
maxTokens: 256_000,
|
||||
models: DEFAULT_DEEPSEEK_MODELS,
|
||||
},
|
||||
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
|
||||
user: { reasoningEffort: 'high' },
|
||||
user: { baseURL: 'https://base' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
},
|
||||
{
|
||||
@@ -119,7 +115,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
},
|
||||
]
|
||||
@@ -263,22 +259,17 @@ describe('ModelsSection', () => {
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
})
|
||||
|
||||
it('decides setup need from the joined credential state and literal-key sidecar', () => {
|
||||
it('decides setup need from the joined credential state', () => {
|
||||
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
|
||||
const row = (
|
||||
credential: ProviderRow['credential'],
|
||||
literalApiKeyConfigured = false,
|
||||
): ProviderRow => ({
|
||||
const row = (credential: ProviderRow['credential']): ProviderRow => ({
|
||||
entry,
|
||||
configured: true,
|
||||
removable: false,
|
||||
apiKeyEnv: 'X',
|
||||
credential,
|
||||
literalApiKeyConfigured,
|
||||
})
|
||||
expect(needsSetup(row(undefined))).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
|
||||
expect(needsSetup(row(undefined, true))).toBe(false)
|
||||
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
|
||||
expect(needsSetup(nested)).toBe(false)
|
||||
})
|
||||
@@ -295,9 +286,7 @@ describe('ModelsSection', () => {
|
||||
expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
|
||||
})
|
||||
|
||||
it('names only the fields the card can see, so an unseen secret survives', () => {
|
||||
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
|
||||
// side, so no op mentions it and the seam leaves it alone.
|
||||
it('names only changed fields instead of rebuilding the section', () => {
|
||||
expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
|
||||
.toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
|
||||
expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
|
||||
@@ -725,20 +714,19 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
|
||||
// The data-loss shape: the old path rebuilt the section from the REDACTED
|
||||
// user layer and replaced it wholesale, deleting any stored literal key.
|
||||
// The old path rebuilt the whole user section to clear one inherited field.
|
||||
const { replace, update, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
|
||||
expect(effort.value).toBe('high')
|
||||
fireEvent.change(effort, { target: { value: '' } })
|
||||
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(url.value).toBe('https://base')
|
||||
fireEvent.change(url, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{ op: 'unset', path: ['reasoningEffort'] }],
|
||||
ops: [{ op: 'unset', path: ['baseURL'] }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
@@ -795,17 +783,14 @@ describe('ModelsSection', () => {
|
||||
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(urls).toHaveLength(2)
|
||||
expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
|
||||
const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
|
||||
fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
|
||||
fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the edited field travels: apiKeyEnv, baseURL and headers were
|
||||
// already stored with these values, so no op restates them — and the
|
||||
// profile's stored literal apiKey, absent from the redacted view the card
|
||||
// read, is named by nothing at all.
|
||||
// Only the edited field travels: apiKeyEnv and headers were already stored
|
||||
// with these values, so no op restates them.
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
|
||||
ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
@@ -1134,8 +1119,8 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
|
||||
// The section rebuild is what dropped stored literal secrets: this page
|
||||
// only ever holds the redacted descriptor, so the removal names the path.
|
||||
// The page only needs to name the profile path; rebuilding the section
|
||||
// would widen the write for no benefit.
|
||||
const { face, mutate, replace, controller } = await mountSection()
|
||||
await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
|
||||
@@ -28,7 +28,6 @@ function harness(options: {
|
||||
providerActive?: boolean
|
||||
settingsNamespace?: boolean
|
||||
apiKeyEnv?: string | null
|
||||
literal?: boolean
|
||||
configured?: () => boolean
|
||||
credential?: { source?: string; writable: boolean }
|
||||
describeFailure?: string
|
||||
@@ -66,7 +65,7 @@ function harness(options: {
|
||||
? {}
|
||||
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: options.literal === true }],
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
})),
|
||||
@@ -153,11 +152,10 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
|
||||
it('skips an absent adapter and an already-configured environment credential', async () => {
|
||||
for (const h of [
|
||||
harness({ provider: false }),
|
||||
harness({ providerSettingsNs: '' }),
|
||||
harness({ literal: true, describeFailure: 'credential seam absent' }),
|
||||
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
|
||||
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
import { ModelsSettingsStore, protocolChoices } from '../src/client/store.ts'
|
||||
import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -66,6 +66,8 @@ function scriptedFace(options: {
|
||||
providers?: Record<string, unknown>
|
||||
/** User layer, when it differs from the effective section. */
|
||||
userProviders?: Record<string, unknown>
|
||||
/** Routes the adapter reports as hand-declared; the rest come back as shipped. */
|
||||
declaredRoutes?: readonly string[]
|
||||
discover?: ReturnType<typeof vi.fn>
|
||||
mutate?: ReturnType<typeof vi.fn>
|
||||
set?: ReturnType<typeof vi.fn>
|
||||
@@ -86,6 +88,7 @@ function scriptedFace(options: {
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', provider],
|
||||
active: true,
|
||||
declared: options.declaredRoutes?.includes(provider) ?? false,
|
||||
})),
|
||||
}))),
|
||||
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
@@ -599,9 +602,59 @@ describe('endpoint interrogation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider rows', () => {
|
||||
it('tags the routes the adapter declared, and only those', async () => {
|
||||
await mountSection({
|
||||
providers: {
|
||||
openai: { apiKeyEnv: 'OPENAI_API_KEY' },
|
||||
'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' },
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
|
||||
const rowOf = (provider: string): HTMLElement => {
|
||||
const row = screen.getByText(provider).closest('li')
|
||||
if (row === null) throw new Error(`no row for ${provider}`)
|
||||
return row
|
||||
}
|
||||
expect(rowOf('acme-gateway').textContent).toContain(en.customTag)
|
||||
// `openai` carries a stored profile too — the tag follows the adapter's
|
||||
// catalog, not the presence of settings, so it stays off here.
|
||||
expect(rowOf('openai').textContent).not.toContain(en.customTag)
|
||||
})
|
||||
|
||||
it('shows no tag when the adapter draws no catalog distinction', async () => {
|
||||
const scripted = scriptedFace({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
||||
scripted.face.llm.providers = vi.fn(() => Promise.resolve(ok({
|
||||
providers: [{
|
||||
provider: 'openai',
|
||||
displayName: 'openai',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
active: true,
|
||||
}],
|
||||
}))) as never
|
||||
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
api={scripted.face as never}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
// Absent is "unknown", never "shipped": an adapter that answers nothing
|
||||
// must not have its routes labelled either way.
|
||||
expect(screen.queryByText(en.customTag)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hand-declared providers', () => {
|
||||
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
|
||||
const scripted = scriptedFace()
|
||||
function mountCard(
|
||||
overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {},
|
||||
wire: Parameters<typeof scriptedFace>[0] = {},
|
||||
) {
|
||||
const scripted = scriptedFace(wire)
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CustomProviderCard
|
||||
@@ -652,6 +705,140 @@ describe('hand-declared providers', () => {
|
||||
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
|
||||
})
|
||||
|
||||
it('scopes each card to fields a provider can actually own', async () => {
|
||||
// Reasoning effort used to sit here. It is a per-MODEL capability and the
|
||||
// models under one provider disagree about it, so a provider-scoped
|
||||
// control could only be set to a value some of them reject — which took
|
||||
// the whole provider out of the picker. The composer's model picker owns
|
||||
// the choice, and a switch there records provider+model+effort together.
|
||||
const fields = () => [...document.querySelectorAll('input,select')]
|
||||
.map(el => el.getAttribute('aria-label')).filter(Boolean)
|
||||
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
|
||||
cleanup()
|
||||
|
||||
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
||||
openEditor('openai')
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(fields()).toEqual([en.keyInput, en.baseUrl])
|
||||
})
|
||||
|
||||
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
||||
const set = vi.fn()
|
||||
.mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected'))
|
||||
.mockResolvedValueOnce(ok({}))
|
||||
const { mutate, onClose } = mountCard({}, { set })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' gw-key ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
// The profile landed; only the key failed. The card says so and stays open.
|
||||
await waitFor(() => { expect(screen.getByText('credential store is read-only')).toBeTruthy() })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(mutate).toHaveBeenCalledTimes(1)
|
||||
// The key is stored trimmed, matching the editor.
|
||||
expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' })
|
||||
|
||||
// The provider exists now, so the fields describing it are settled and
|
||||
// only the key can still be corrected.
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).disabled).toBe(true)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).disabled).toBe(false)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key-2' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
||||
// Re-running the profile write would carry the revision this card's own
|
||||
// first write superseded, so the Host would answer settings-conflict and
|
||||
// the key could never be stored from here at all.
|
||||
expect(mutate).toHaveBeenCalledTimes(1)
|
||||
expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' })
|
||||
})
|
||||
|
||||
it('reports the created provider when cancelled after its profile landed', async () => {
|
||||
const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected'))
|
||||
const { onClose } = mountCard({}, { set })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
await waitFor(() => { expect(screen.getByText('nope')).toBeTruthy() })
|
||||
|
||||
// Walking away leaves a real provider behind; reporting no change would
|
||||
// leave the page without the row it now has.
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
expect(onClose).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('never contradicts a filled-in field with the next gate\u2019s copy', () => {
|
||||
mountCard()
|
||||
const routeField = screen.getByLabelText(en.customRoute)
|
||||
fireEvent.change(routeField, { target: { value: '2' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
|
||||
// The route field explains itself right under the input; the shared line
|
||||
// must stay silent rather than falling through to "no models yet" while
|
||||
// the list above plainly has one.
|
||||
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
|
||||
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
|
||||
|
||||
// Fixing the route hands the line back to the gate that is actually unmet.
|
||||
fireEvent.change(routeField, { target: { value: 'acme' } })
|
||||
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
|
||||
expect(buttonNamed(en.create).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a route id whose derived credential reference would be illegal', () => {
|
||||
mountCard()
|
||||
const routeField = screen.getByLabelText(en.customRoute)
|
||||
fireEvent.change(routeField, { target: { value: 'https://acme.test/v1' } })
|
||||
|
||||
// A digit-leading id used to pass every check this card makes and then
|
||||
// fail at the credential seam with a raw regular expression: the
|
||||
// reference derives as `123_API_KEY`, and a credential reference is a
|
||||
// POSIX shell identifier, which cannot start with a digit.
|
||||
fireEvent.change(routeField, { target: { value: '123' } })
|
||||
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
|
||||
fireEvent.change(routeField, { target: { value: 'a1' } })
|
||||
expect(screen.queryByText(en.customRouteInvalid)).toBeNull()
|
||||
})
|
||||
|
||||
it('styles a rejected route id as a fault and its guidance as a hint', () => {
|
||||
mountCard()
|
||||
const routeField = screen.getByLabelText(en.customRoute)
|
||||
// Same split the key field makes: what the user got wrong reads as a
|
||||
// fault, what they have yet to do reads as guidance.
|
||||
expect(screen.getByText(en.customRouteHint).className).toMatch(/advancedHint/)
|
||||
|
||||
fireEvent.change(routeField, { target: { value: '2' } })
|
||||
expect(screen.getByText(en.customRouteInvalid).className).toMatch(/error/)
|
||||
|
||||
fireEvent.change(routeField, { target: { value: 'openai' } })
|
||||
expect(screen.getByText(en.customRouteTaken).className).toMatch(/error/)
|
||||
})
|
||||
|
||||
it('derives a reference the credential seam accepts for every id it admits', () => {
|
||||
// The two rules have to stay in step; this is the relation, checked
|
||||
// directly rather than through the DOM.
|
||||
const CREDENTIAL_REF = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
for (const id of ['a', 'ds', 'a1', 'acme-gateway', 'x-1-y', 'zz9']) {
|
||||
expect(CREDENTIAL_REF.test(deriveKeyRef(id))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
@@ -815,8 +1002,10 @@ describe('hand-declared providers', () => {
|
||||
|
||||
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
||||
// No display name configured means none stored; the route id is the name.
|
||||
// No key typed means no reference either, matching the editor: the route
|
||||
// keeps its provider-native auth path instead of resolving a reference
|
||||
// nothing ever sets. The with-key case is covered above.
|
||||
expect(firstMutate(mutate).ops[0]?.value).toEqual({
|
||||
apiKeyEnv: 'ACME_API_KEY',
|
||||
api: 'anthropic-messages',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
models: [{ id: 'm' }],
|
||||
|
||||
@@ -19,7 +19,6 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: missingCredential,
|
||||
literalApiKeyConfigured: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -64,13 +63,6 @@ describe('deepSeekReadiness', () => {
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
})
|
||||
|
||||
it('accepts the redacted literal-key sidecar before judging the credential domain', () => {
|
||||
expect(deepSeekReadiness(state({
|
||||
credentialError: 'credentials service absent',
|
||||
rows: [row({ literalApiKeyConfigured: true, credential: undefined })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
})
|
||||
|
||||
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
|
||||
@@ -25,7 +25,7 @@ const NAMESPACES = [
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
|
||||
base: { baseURL: 'https://base' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
},
|
||||
{
|
||||
@@ -85,7 +85,6 @@ describe('ModelsSettingsStore', () => {
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: { configured: false, writable: true },
|
||||
literalApiKeyConfigured: false,
|
||||
})
|
||||
expect(byProvider.get('openai')).toMatchObject({
|
||||
configured: true,
|
||||
@@ -131,30 +130,6 @@ describe('ModelsSettingsStore', () => {
|
||||
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
|
||||
})
|
||||
|
||||
it('joins a configured literal key from the redacted secret sidecar', async () => {
|
||||
const { face } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
...NAMESPACES[0],
|
||||
secrets: [
|
||||
{ path: ['apiKey', 'nested'], set: true },
|
||||
{ path: ['different'], set: true },
|
||||
{ path: ['apiKey'], set: true },
|
||||
],
|
||||
}] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot().rows[0]).toMatchObject({
|
||||
literalApiKeyConfigured: true,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a directory failure and keeps the last good rows', async () => {
|
||||
const { face } = api()
|
||||
const store = new ModelsSettingsStore(face)
|
||||
|
||||
@@ -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-primitives/README.md
|
||||
README.md: a5f35c9a41008921c6a1e3a4de3fd3f38af926c0
|
||||
README.zh.md: 54c70ed29e73e196a9be60ddb794c12187dbe0c5
|
||||
README.md: e6ca935426903ec0cc99f3fcacc1f937aa60b4ba
|
||||
README.zh.md: 4c23e7bd78a8ddde3e3cfe354203e81d3bb5b5e1
|
||||
|
||||
@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## Hover cards
|
||||
|
||||
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md).
|
||||
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Historical rationale: [the archived hover-card copy note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md).
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 悬浮卡片
|
||||
|
||||
`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。
|
||||
`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。历史依据见[已归档的悬浮卡片复制 Agent Note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md)。
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* The shell renders the surrounding chrome (button, nav heading row) and
|
||||
* reads each entry's `label` option for aria text.
|
||||
*/
|
||||
import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconSettingsOutline14, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './chrome.module.css'
|
||||
|
||||
@@ -22,7 +22,7 @@ export type HeaderContentProps = PropsRuntime<'settings.header'> & PropsLocale<'
|
||||
export function TriggerContent({ wide, t }: TriggerContentProps) {
|
||||
return (
|
||||
<>
|
||||
<IconSettingsOutline14 size={wide ? 14 : 18} />
|
||||
{wide ? <IconSettingsOutline16 size={16} /> : <IconSettingsOutline14 size={18} />}
|
||||
{wide && <span className={css.triggerLabel}>{t('trigger')}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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-theme/README.md
|
||||
README.md: 88e21fe214ec806b101050949690283d811be36d
|
||||
README.zh.md: ba781ba89a62292928a7b05ab94ea1cd930b4f50
|
||||
README.md: 648213b258169b2e8869a93d058fcc65aece175e
|
||||
README.zh.md: b807ebd66253c91dc8b79f01ac4d3b2335682001
|
||||
|
||||
@@ -21,4 +21,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete.
|
||||
- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22).
|
||||
- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). Design-owner-approved additions are the exception and enter as a static step plus a semantic alias in the same change (`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`, 2026-08-07).
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **第三方主题是表层,不是产品**:注册主题意味着覆盖同名别名变量;目前不会验证一组覆盖是否完整。
|
||||
- **token 样式表是颜色值的唯一权威来源**:会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token(裁定于 2026-07-22)。
|
||||
- **token 样式表是颜色值的唯一权威来源**:会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token(裁定于 2026-07-22)。设计负责人批准的新增值是例外:须在同一变更中以一个 static 梯度值加一个语义 alias 的形式进入(`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`,2026-08-07)。
|
||||
|
||||
@@ -17,6 +17,7 @@ body {
|
||||
--dsw-static-blue-600: rgb(37, 99, 235);
|
||||
--dsw-static-blue-75: rgb(229, 240, 255);
|
||||
--dsw-static-blue-800: rgb(30, 64, 175);
|
||||
--dsw-static-blue-900: rgb(14, 48, 116);
|
||||
--dsw-static-blue-950: rgb(23, 37, 84);
|
||||
--dsw-static-deepseek-100: rgb(228, 237, 253);
|
||||
--dsw-static-deepseek-200: rgb(211, 226, 255);
|
||||
@@ -92,6 +93,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-static-blue-600: rgb(37, 99, 235);
|
||||
--dsw-static-blue-75: rgb(229, 240, 255);
|
||||
--dsw-static-blue-800: rgb(30, 64, 175);
|
||||
--dsw-static-blue-900: rgb(14, 48, 116);
|
||||
--dsw-static-blue-950: rgb(23, 37, 84);
|
||||
--dsw-static-deepseek-100: rgb(228, 237, 253);
|
||||
--dsw-static-deepseek-200: rgb(211, 226, 255);
|
||||
@@ -197,6 +199,7 @@ body {
|
||||
--dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06);
|
||||
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400);
|
||||
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200);
|
||||
--dsw-alias-label-primary-bluish: var(--dsw-static-blue-900);
|
||||
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950);
|
||||
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00);
|
||||
@@ -287,6 +290,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08);
|
||||
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800);
|
||||
|
||||
Reference in New Issue
Block a user