Merge remote-tracking branch 'origin/master' into feat/plugin-owned-settings-surface
# Conflicts: # packages/client/ui-plugin-config/README.i18n.yaml # packages/client/ui-plugin-config/README.md # packages/client/ui-plugin-config/README.zh.md # packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx # packages/client/ui-plugin-config/src/client/index.ts # packages/client/ui-plugin-config/tests/apply.client.spec.ts # packages/client/ui-plugin-config/tests/section.client.spec.tsx
This commit is contained in:
@@ -78,6 +78,11 @@
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
|
||||
# Whole-log turn/step counts for the chat stats strip (the sessionStats
|
||||
# projection key); the projection registry itself is a base-layer row.
|
||||
- id: session-stats
|
||||
name: '@deepseek-ai/dsh-session-stats'
|
||||
|
||||
# Resolve bind host, SSH launch, and display once at boot, then mount the
|
||||
# matching dual-face directory picker. Mount -native or -browse directly in
|
||||
# an overlay to pin the interaction.
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-export": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:^",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: 236531281c17ef982982e97caad99491584bd0b5
|
||||
README.zh.md: 73bc3e31c90a4f12c4c5f11e9fd0552601dcc7a6
|
||||
README.md: b9452d1f763be5be6953cb7973da8a2c909ed979
|
||||
README.zh.md: 0dfb6e6b6d619f111e36d5bdf57d127ac1ca9ef5
|
||||
|
||||
@@ -35,13 +35,13 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. |
|
||||
| [`ui-plugin-config/`](ui-plugin-config/README.md) | Owns the Plugins settings section, its tab extension point, and configurable host-plane plugin cards. |
|
||||
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
|
||||
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
|
||||
| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. |
|
||||
| [`ui-plugins/`](ui-plugins/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings. |
|
||||
|
||||
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-model/`](ui-model/README.md) | 在对话界面中提供模型选择。 |
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 |
|
||||
| [`ui-plugin-config/`](ui-plugin-config/README.md) | 拥有“插件”设置分区、它的标签页扩展点,以及可配置的宿主平面插件卡片。 |
|
||||
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并编写预设组合。 |
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
|
||||
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
|
||||
| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 |
|
||||
| [`ui-plugins/`](ui-plugins/README.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页。 |
|
||||
|
||||
每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createAssistantMessage,
|
||||
createToolResultMessage,
|
||||
createUserMessage,
|
||||
isTokenDelta,
|
||||
} from '@deepseek-ai/dsh-llm/message'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type {
|
||||
@@ -876,6 +877,76 @@ function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection
|
||||
return totals
|
||||
}
|
||||
|
||||
/** Fixture parallel of session-stats' whole-log counting and wall-time fold. */
|
||||
function sessionStatsOf(log: readonly SessionEvent[]): {
|
||||
turns: number
|
||||
steps: number
|
||||
llmMs: number
|
||||
toolMs: number
|
||||
ttftMs: number
|
||||
ttftSteps: number
|
||||
decodeMs: number
|
||||
decodeTokens: number
|
||||
} {
|
||||
const value = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
|
||||
let lastTurn: number | null = null
|
||||
let openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null = null
|
||||
const pendingCalls = new Map<string, number>()
|
||||
for (const event of log) {
|
||||
switch (event.type) {
|
||||
case 'step/start':
|
||||
openStep = { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null }
|
||||
break
|
||||
case 'assistant/chunk':
|
||||
if (openStep !== null && openStep.turn === event.data.turn && openStep.step === event.data.step
|
||||
&& openStep.firstTokenTime === null && isTokenDelta(event.data.chunk)) {
|
||||
openStep.firstTokenTime = event.time
|
||||
}
|
||||
break
|
||||
case 'assistant/message': {
|
||||
if (openStep === null || openStep.turn !== event.data.turn || openStep.step !== event.data.step) break
|
||||
value.llmMs += Math.max(0, event.time - openStep.startTime)
|
||||
if (openStep.firstTokenTime !== null) {
|
||||
value.ttftMs += Math.max(0, openStep.firstTokenTime - openStep.startTime)
|
||||
value.ttftSteps += 1
|
||||
const outputTokens = event.data.usage?.outputTokens
|
||||
if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) {
|
||||
value.decodeMs += Math.max(0, event.time - openStep.firstTokenTime)
|
||||
value.decodeTokens += outputTokens
|
||||
}
|
||||
}
|
||||
openStep = null
|
||||
break
|
||||
}
|
||||
case 'tool/call':
|
||||
pendingCalls.set(event.data.callId, event.time)
|
||||
break
|
||||
case 'tool/result': {
|
||||
const callId = event.data.message.source.callId
|
||||
const dispatched = pendingCalls.get(callId)
|
||||
if (dispatched === undefined) break
|
||||
pendingCalls.delete(callId)
|
||||
value.toolMs += Math.max(0, event.time - dispatched)
|
||||
break
|
||||
}
|
||||
case 'step/end':
|
||||
if (event.data.turn !== lastTurn) {
|
||||
value.turns += 1
|
||||
lastTurn = event.data.turn
|
||||
}
|
||||
value.steps += 1
|
||||
openStep = null
|
||||
break
|
||||
case 'turn/end':
|
||||
pendingCalls.clear()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
interface FixtureRequestContext {
|
||||
provider: string
|
||||
model: string
|
||||
@@ -994,6 +1065,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
values['contextPressure'] = contextPressureOf(log)
|
||||
// Always present (token-meter composed): heuristic request composition.
|
||||
values['contextBreakdown'] = contextBreakdownOf(log)
|
||||
// Always present (session-stats unit composed): whole-log turn/step counts.
|
||||
values['sessionStats'] = sessionStatsOf(log)
|
||||
// Always present (attachment service composed): the deployment image
|
||||
// limits, constant per boot (mirrors the attachment-local defaults).
|
||||
// Deliberate host divergence: the real gateway never pushes an imageLimits
|
||||
@@ -1041,6 +1114,17 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
seq: event.seq,
|
||||
})
|
||||
}
|
||||
// The stats fold's view advances on message assembly and tool settlement
|
||||
// (wall times) and on step close (counts).
|
||||
if (type === 'assistant/message' || type === 'tool/result' || type === 'step/end') {
|
||||
frames.push({
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'sessionStats',
|
||||
value: sessionStatsOf(log),
|
||||
seq: event.seq,
|
||||
})
|
||||
}
|
||||
if (frames.length > 0) return frames
|
||||
if (type === 'session/title') {
|
||||
const values = projectionValuesOf(log)
|
||||
|
||||
@@ -164,6 +164,10 @@ describe('createFixtureApi', () => {
|
||||
toolsTokens: 0,
|
||||
messageTokens: 0,
|
||||
},
|
||||
// Session-stats unit composed: no figure accrues on the empty log.
|
||||
sessionStats: {
|
||||
turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
|
||||
},
|
||||
imageLimits: {
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxImagesPerMessage: 20,
|
||||
@@ -360,7 +364,7 @@ describe('createFixtureApi', () => {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 11) abort.abort()
|
||||
if (envelopes.length >= 13) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -381,14 +385,16 @@ describe('createFixtureApi', () => {
|
||||
value: { systemTokens: 0, toolsTokens: 0 },
|
||||
})
|
||||
expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
|
||||
expect(first[9]?.payload).toMatchObject({
|
||||
expect(first[9]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'sessionStats' })
|
||||
expect((first[9]?.payload as { value: { turns: number; steps: number } }).value.steps).toBeGreaterThan(0)
|
||||
expect(first[10]?.payload).toMatchObject({
|
||||
type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits',
|
||||
value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 },
|
||||
})
|
||||
expect(first[10]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[10]?.rpcId).toBe(first[10]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[11]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[11]?.rpcId).toBe(first[11]?.rpcId)
|
||||
expect(first[11]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[11]?.rpcId).toBe(first[11]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[12]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[12]?.rpcId).toBe(first[12]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
// history fold derive AssistantTiming from the same step/start -> first token
|
||||
// delta -> assistant/message sequence.
|
||||
|
||||
import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { AssistantTiming } from './conversation.ts'
|
||||
|
||||
// The first-token predicate lives beside the StreamChunk type in dsh-llm;
|
||||
// re-exported here so Chat Definitions keep their client-runtime import.
|
||||
export { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
|
||||
|
||||
/** Pre-finalize timing boundaries for one assistant step (start + first token). */
|
||||
export interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
@@ -21,24 +26,6 @@ export function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a chunk carries visible model output (first-token boundary). Empty
|
||||
* deltas (heartbeats, empty tool-call frames) do not count as a first token.
|
||||
* @param chunk - the assistant/chunk payload.
|
||||
* @returns true when the chunk contains a non-empty text/reasoning/tool delta.
|
||||
*/
|
||||
export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one event into the per-step timing index: step/start opens the entry,
|
||||
* the first non-empty token delta stamps first-token time once. Other event
|
||||
|
||||
@@ -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: b6a265c9f0a67d31ebeaa88bd59082c4be465983
|
||||
README.zh.md: 001e0a58badd6f31875c09a31085277928f1ae22
|
||||
README.md: 131f76fc8bc7b63449e022ad91fd8453c10d5f01
|
||||
README.zh.md: 8d0cd4e82b8bf4256db9b0f3cb868630f57ce68e
|
||||
|
||||
@@ -40,7 +40,7 @@ Image intake accepts paste and whole-page drop: the bar binds document-level dra
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists.
|
||||
|
||||
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
|
||||
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
|
||||
|
||||
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
|
||||
|
||||
@@ -56,7 +56,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **The stats-line fallback fold covers the in-window flow only** — without the `sessionStats` projection (an assembly that does not mount the unit), every figure folds the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted and the numbers grow per loaded page.
|
||||
- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
|
||||
- **Sent user messages cannot be edited** — user bubbles retain clock and copy; branch lives only under assistant answers ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)). Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
|
||||
|
||||
@@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
|
||||
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
|
||||
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
|
||||
|
||||
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
|
||||
|
||||
@@ -56,7 +56,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具调用/工具结果配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **统计行的回退折算只覆盖窗口内消息流**:未组合 `sessionStats` 投影单元的装配中,所有数字由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入,数字随加载页数增长。
|
||||
- **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
|
||||
- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;分支只存在于 assistant 回答之下([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
@@ -98,6 +99,7 @@
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'reac
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
|
||||
import type {} from '@deepseek-ai/dsh-session-stats/client'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { formatTokensPerSecond } from './message-chrome.ts'
|
||||
@@ -30,14 +32,16 @@ interface WindowStats {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold assistant and tool-result nodes into the window-scoped display totals.
|
||||
* Fold assistant and tool-result nodes into window-scoped display totals —
|
||||
* the FALLBACK for assemblies without the `sessionStats` projection.
|
||||
*
|
||||
* Counts and wall times describe the loaded window on purpose — they answer
|
||||
* "what is on screen". Token accounting deliberately does NOT come from here:
|
||||
* the window is paged and compaction rewrites it, so billing rides the durable
|
||||
* `tokenUsage` projection instead.
|
||||
* Every displayed figure rides that durable whole-log projection (and token
|
||||
* accounting rides `tokenUsage`) because the window is paged and compaction
|
||||
* rewrites it; this fold answers "what is on screen" only when no projection
|
||||
* value is served. Its field names deliberately mirror the projection's so
|
||||
* the two swap wholesale.
|
||||
* @param nodes - snapshot nodes.
|
||||
* @returns visible counts and summed wall times.
|
||||
* @returns fallback counts and summed wall times.
|
||||
*/
|
||||
export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
|
||||
const turns = new Set<number>()
|
||||
@@ -158,8 +162,13 @@ export interface StatsLineProps {
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
|
||||
const settledNodes = useSession(s => s.chat.legacy.nodes)
|
||||
const stats = useMemo(() => deriveStats(settledNodes), [settledNodes])
|
||||
const usage = useProjection('tokenUsage')
|
||||
// Every figure rides the durable sessionStats projection, so paging and
|
||||
// compaction cannot change any of them; an assembly without the unit falls
|
||||
// back to the window-scoped fold wholesale (same field names), paid only
|
||||
// while no projection value is served.
|
||||
const projected = useProjection('sessionStats')
|
||||
const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
|
||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||
const groups: string[] = []
|
||||
if (stats.steps > 0) {
|
||||
@@ -168,7 +177,6 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t
|
||||
if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) }))
|
||||
if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) }))
|
||||
if (durations.length > 0) groups.push(durations.join(' · '))
|
||||
// Window-scoped like the wall times above: averages describe loaded steps.
|
||||
const speeds: string[] = []
|
||||
if (stats.ttftSteps > 0) {
|
||||
speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }))
|
||||
@@ -183,9 +191,11 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t
|
||||
// Context occupancy deliberately lives on the composer's ContextMeter ring,
|
||||
// not here — one home per fact.
|
||||
// Billing rides the durable projection, so these survive paging and
|
||||
// compaction. Suppress the empty projection on a brand-new session.
|
||||
// compaction. Gated on actual token activity: a session whose steps all
|
||||
// settled without billing (e.g. every request failed) shows its counts
|
||||
// without a zero-token group.
|
||||
if (usage !== undefined
|
||||
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
|
||||
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
|
||||
const cacheHit = cacheHitPercent(usage)
|
||||
if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
|
||||
groups.push(t('stats.tokens', {
|
||||
|
||||
@@ -98,9 +98,11 @@ describe('deriveStats', () => {
|
||||
])
|
||||
expect(stats.turns).toBe(2)
|
||||
expect(stats.steps).toBe(3)
|
||||
// Window-scoped by design: the paged window is not an accounting source, so
|
||||
// the fold exposes no billing fields (billing rides the projection);
|
||||
// decodeTokens is a throughput input, not a billed total.
|
||||
// The window fold's counts are only the fallback for assemblies without
|
||||
// the sessionStats projection; the paged window is not an accounting
|
||||
// source either, so the fold exposes no billing fields (billing rides the
|
||||
// tokenUsage projection); decodeTokens is a throughput input, not a
|
||||
// billed total.
|
||||
expect(Object.keys(stats).sort()).toEqual(
|
||||
['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'],
|
||||
)
|
||||
@@ -169,6 +171,14 @@ describe('formatters', () => {
|
||||
describe('StatsLine', () => {
|
||||
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
|
||||
|
||||
/** A whole-log sessionStats value: zeros plus overrides. */
|
||||
function sessionStats(overrides: Record<string, number>): Record<string, number> {
|
||||
return {
|
||||
turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Stub the projection seat: a key-addressed table of whole values. */
|
||||
function projections(values: Record<string, unknown>): StatsLineProps['useProjection'] {
|
||||
return (key: string) => values[key]
|
||||
@@ -281,6 +291,69 @@ describe('StatsLine', () => {
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps')
|
||||
})
|
||||
|
||||
it('renders whole-session counts from the sessionStats projection over the paged window', () => {
|
||||
// The bug's acceptance at unit level: one loaded page must not scope the
|
||||
// counter — the durable projection's totals win over the window fold.
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
sessionStats: sessionStats({ turns: 10, steps: 89 }),
|
||||
})} />)
|
||||
expect(view.container.textContent)
|
||||
.toBe('10 turns · 89 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
})
|
||||
|
||||
it('treats a defined zero-count projection as empty, not as fallback', () => {
|
||||
// A composed unit always serves the key; all-zero genuinely means no
|
||||
// closed step in the whole log, so nothing renders on a brand-new session.
|
||||
const empty = makeSource()
|
||||
const view = render(<StatsLine {...props(empty.source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
sessionStats: sessionStats({}),
|
||||
})} />)
|
||||
expect(view.container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('hides the zero-token group when steps closed without any billed activity', () => {
|
||||
// A session whose only turn failed before billing (e.g. an auth error):
|
||||
// the counts group renders alone, not an uninformative zero-token group.
|
||||
const { source } = makeSource()
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
sessionStats: sessionStats({ turns: 1, steps: 1 }),
|
||||
})} />)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps')
|
||||
})
|
||||
|
||||
it('keeps the counts group over an empty visible window when the projection carries totals', () => {
|
||||
// Extends the durable-groups guarantee: full-session counts survive a
|
||||
// window that compaction (or paging) left without assistant nodes.
|
||||
const { source } = makeSource()
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
sessionStats: sessionStats({ turns: 7, steps: 44 }),
|
||||
})} />)
|
||||
expect(view.container.textContent)
|
||||
.toBe('7 turns · 44 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
})
|
||||
|
||||
it('renders whole-log wall times and speeds from the projection, not the loaded window', () => {
|
||||
// The 加载更早 hazard beyond counts: LLM/tool durations and the TTFT and
|
||||
// throughput figures must not grow per loaded page either. An untimed
|
||||
// 1-node window renders the projection's whole-log figures verbatim.
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
sessionStats: sessionStats({
|
||||
turns: 200, steps: 200, llmMs: 100_000, toolMs: 62_000,
|
||||
ttftMs: 1_600, ttftSteps: 2, decodeMs: 3_000, decodeTokens: 60,
|
||||
}),
|
||||
})} />)
|
||||
expect(view.container.textContent).toBe(
|
||||
'200 turns · 200 steps| LLM 1m40s · Tool call 1m2s| TTFT avg 0.8s · 20 tok/s| Cache hit 90%| Input 100 tok · Output 5 tok',
|
||||
)
|
||||
})
|
||||
|
||||
it('omits cache hit when nothing was billed on the input side', () => {
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
|
||||
@@ -70,9 +70,11 @@ describe('render branch tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('StatsLine counts window nodes but drops every token group without a projection', () => {
|
||||
// Node `usage` is deliberately ignored: billing rides the durable
|
||||
// tokenUsage projection, so an absent projection leaves counts only.
|
||||
it('StatsLine falls back to window-node counts and drops every token group without projections', () => {
|
||||
// No sessionStats key → the window fold supplies the counts (the
|
||||
// assembly-without-the-unit fallback). Node `usage` is deliberately
|
||||
// ignored: billing rides the durable tokenUsage projection, so an absent
|
||||
// projection leaves counts only.
|
||||
const nodes = [
|
||||
{ kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-stats"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
|
||||
@@ -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-plugin-config/README.md
|
||||
README.md: c4cda446b03d98eca01264a6a476999572f9e21f
|
||||
README.zh.md: 9ddef246f1f80b310ac6f1b292cbd85f5306da49
|
||||
README.md: 6f5caf0c2a090ca83e175b5b20ff309dfa85da1d
|
||||
README.zh.md: 90c1bd29669d29c861a106cbac246f3a3765c5ea
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
|
||||
The **Plugins** settings section and its **Plugin configuration** tab. The section owns the heading and compact tab chrome; feature plugins contribute pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
|
||||
|
||||
## What appears here
|
||||
|
||||
The section reads which settings namespaces the Host serves and dispatches one slot key per namespace, so what renders is the intersection of two ledgers: the namespaces a live Host plugin registered, and the cards registered under those keys. A served namespace no card claims renders nothing — another surface owns it, or this deployment ships no browser half for it — and a card whose namespace this deployment does not serve is never dispatched, so an uncomposed plugin leaves no trace and does not hold the section back from its empty line. Cards appear in the order they registered, which is stable for the cards one package installs together and not stable across plugins: apply order between packages is unconstrained. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin".
|
||||
The configurable tab reads which settings namespaces the Host serves and dispatches one slot key per namespace, so what renders is the intersection of two ledgers: the namespaces a live Host plugin registered, and the cards registered under those keys. A served namespace no card claims renders nothing — another surface owns it, or this deployment ships no browser half for it — and a card whose namespace this deployment does not serve is never dispatched, so an uncomposed plugin leaves no trace and does not hold the tab back from its empty line. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin". Cards appear in the order they registered, which is stable for the cards one package installs together and not stable across plugins: apply order between packages is unconstrained.
|
||||
|
||||
The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
|
||||
|
||||
## Extension point
|
||||
|
||||
The section declares `settings.plugin.item`, a root keyed slot whose key is the settings namespace a card edits. A plugin that ships a browser half registers its own card under its own namespace and owns every part of it — chrome, controls, and copy; this package supplies no form it was not given and never learns what a namespace means. Keying on the namespace is what lets a plugin distributed outside this repository appear here: it registers the namespace on the Host and the card in the browser, and the section pairs the two.
|
||||
The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` slot — keyed on the settings namespace a card edits. A plugin that ships a browser half registers its own card under its own namespace and owns every part of it: chrome, controls, and copy. Keying on the namespace is what lets a plugin distributed outside this repository appear here — it registers the namespace on the Host and the card in the browser, and the tab pairs the two without learning what the namespace means. Tabs follow the contribution's `order`; cards follow registration order.
|
||||
|
||||
## Writes
|
||||
|
||||
@@ -36,5 +36,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job.
|
||||
- **A card still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. The bundle-purity gate also forbids importing this package's card chrome or form model as values, so such a card owns its own staging and revision fencing.
|
||||
- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the section's read joins the list on the next document commit or reconnect.
|
||||
- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the tab's read joins the list on the next document commit or reconnect.
|
||||
- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card.
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
|
||||
**插件**设置分区及其**插件配置**标签页。该分区拥有标题与紧凑的标签栏;功能插件通过 `settings.plugins.tab` 贡献页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
|
||||
|
||||
## 这里会出现什么
|
||||
|
||||
本分区读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染——它归别的界面所有,或本部署没有为它提供浏览器半侧;而命名空间未被本部署服务的卡片根本不会被派发,因此未组装的插件不留任何痕迹,也不会挡住那行空态文案。卡片按自身注册的顺序出现;对同一个包一起安装的卡片这是稳定的,对跨插件的卡片则不稳定:包与包之间的 apply 顺序是无约束的。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成"本部署没有可配置的插件"。
|
||||
“插件配置”标签页读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染——它归别的界面所有,或本部署没有为它提供浏览器半侧;而命名空间未被本部署服务的卡片根本不会被派发,因此未组装的插件不留任何痕迹,也不会挡住那行空态文案。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成“本部署没有可配置的插件”。卡片按自身注册的顺序出现;对同一个包一起安装的卡片这是稳定的,对跨插件的卡片则不稳定:包与包之间的 apply 顺序是无约束的。
|
||||
|
||||
本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
|
||||
## 扩展点
|
||||
|
||||
本分区声明了根级 keyed slot `settings.plugin.item`,其键就是卡片所编辑的 settings 命名空间。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部——外观、控件与文案;本包不提供任何未被交给它的表单,也从不知道某个命名空间意味着什么。以命名空间为键,正是在本仓库之外分发的插件能出现在这里的原因:它在 Host 上注册命名空间、在浏览器里注册卡片,由本分区把两者配对。
|
||||
本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` slot——以卡片所编辑的 settings 命名空间为键。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部:外观、控件与文案。以命名空间为键,正是在本仓库之外分发的插件能出现在这里的原因——它在 Host 上注册命名空间、在浏览器里注册卡片,由标签页把两者配对,而无需知道命名空间意味着什么。标签页遵循贡献的 `order`;卡片遵循注册顺序。
|
||||
|
||||
## 写入
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
|
||||
- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
|
||||
- **卡片仍然需要一份浏览器 bundle**——浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包,因此本仓库之外的插件得自行复刻该构建。bundle 纯净度门禁同时禁止以值的形式导入本包的卡片外观与表单模型,所以这样的卡片要自行拥有暂存与 revision 设栅。
|
||||
- **被服务的命名空间只在两种信号上重读**——协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在本分区读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入列表。
|
||||
- **被服务的命名空间只在两种信号上重读**——协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在标签页读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入列表。
|
||||
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plugin-config",
|
||||
"description": "Plugin configuration section: host-plane plugin settings as expandable cards",
|
||||
"description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Configurable Host plugins contributed to the shared Plugins section.
|
||||
*
|
||||
* The tab enumerates settings namespaces but never interprets one — a card
|
||||
* arrives through `settings.plugin.item` keyed by the namespace it edits, so a
|
||||
* plugin that ships a browser half owns its own card and this tab only decides
|
||||
* which keys to dispatch.
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from './slot-contract.ts'
|
||||
import type { ConfigurablePluginsTabFace } from './tab-store.ts'
|
||||
import css from './PluginConfigSection.module.css'
|
||||
|
||||
/** Props the renderer binds for the configurable tab. */
|
||||
export type ConfigurablePluginsTabProps =
|
||||
PropsRuntime<'settings.plugins.tab'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& PropsRenderSlots<'settings.plugin.item'>
|
||||
& InjectFace<ConfigurablePluginsTabFace>
|
||||
|
||||
/**
|
||||
* Render cards registered by plugins that expose editable settings.
|
||||
* @param props - locale copy, slot rendering, and the namespaces to dispatch.
|
||||
* @returns the card list, or the empty line once the Host has answered.
|
||||
*/
|
||||
export function ConfigurablePluginsTab(props: ConfigurablePluginsTabProps) {
|
||||
const { t, renderSlot } = props
|
||||
const { loaded, namespaces } = props.useConfigurablePlugins(snapshot => snapshot)
|
||||
if (namespaces.length > 0) {
|
||||
return (
|
||||
<ul className={css.cards}>
|
||||
{namespaces.map(ns => (
|
||||
// One dispatch per namespace, so the list identity is the namespace
|
||||
// rather than a position that shifts as cards arrive.
|
||||
<Fragment key={ns}>{renderSlot('settings.plugin.item', {}, { entryKey: ns })}</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
return loaded ? <p className={css.empty}>{t('empty')}</p> : null
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* Plugin configuration section: heading, intro, and the card list. */
|
||||
/* Plugins section: compact tabs plus the configurable plugin card list. */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
max-width: 760px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,55 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 22px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
border: 0;
|
||||
padding: 7px 1px 9px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:hover,
|
||||
.tab[data-active='true'] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tab[data-active='true']::after,
|
||||
.tab:focus-visible::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -1,55 +1,123 @@
|
||||
/**
|
||||
* Plugin configuration section: the shell around the per-plugin cards. It
|
||||
* enumerates settings namespaces but never interprets one — a card arrives
|
||||
* through the `settings.plugin.item` slot keyed by the namespace it edits, so
|
||||
* a plugin that ships a browser half owns its own card and this section only
|
||||
* decides which keys to dispatch.
|
||||
*/
|
||||
/** Plugins settings section: localized tabs around feature-owned pages. */
|
||||
|
||||
import { Fragment } from 'react'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from './slot-contract.ts'
|
||||
import type { PluginConfigSectionFace } from './section-store.ts'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import type {
|
||||
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PluginConfigKey } from './locales.ts'
|
||||
import css from './PluginConfigSection.module.css'
|
||||
|
||||
/** One tab projected from a `settings.plugins.tab` contribution. */
|
||||
export interface PluginSettingsTabRow {
|
||||
id: string
|
||||
order: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Registration-side business face for the section. */
|
||||
export interface PluginConfigSectionInjected {
|
||||
hooks: {
|
||||
/** Ordered, locale-aware projection of the Plugins tab ledger. */
|
||||
tabs: HostObservable<readonly PluginSettingsTabRow[]>
|
||||
}
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the section. */
|
||||
export type PluginConfigSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& PropsRenderSlots<'settings.plugin.item'>
|
||||
& InjectFace<PluginConfigSectionFace>
|
||||
& PropsRenderSlots<'settings.plugins.tab'>
|
||||
& InjectFace<PluginConfigSectionInjected>
|
||||
|
||||
/** Render one Plugins page whose contents arrive from feature-owned tabs. */
|
||||
export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) {
|
||||
const tabsId = useId()
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const rows = useTabs(value => value)
|
||||
const [activeId, setActiveId] = useState<string>()
|
||||
const [visitedIds, setVisitedIds] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id
|
||||
|
||||
// A tab mounts only when first selected, then stays mounted while hidden so
|
||||
// local drafts, disclosure state, search, and the inventory snapshot survive
|
||||
// switching between the two views.
|
||||
useEffect(() => {
|
||||
if (active === undefined) return
|
||||
setVisitedIds((previous) => {
|
||||
if (previous.has(active)) return previous
|
||||
return new Set([...previous, active])
|
||||
})
|
||||
}, [active])
|
||||
|
||||
/**
|
||||
* Render the plugin configuration section.
|
||||
* @param props - runtime slot rendering, locale copy, and the namespaces to dispatch.
|
||||
* @returns the section.
|
||||
*/
|
||||
export function PluginConfigSection(props: PluginConfigSectionProps) {
|
||||
const { t, renderSlot } = props
|
||||
const { loaded, namespaces } = props.usePluginConfigSection(snapshot => snapshot)
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.heading}>{t('title')}</h2>
|
||||
<p className={css.intro}>{t('intro')}</p>
|
||||
{namespaces.length > 0
|
||||
? (
|
||||
<ul className={css.cards}>
|
||||
{namespaces.map(ns => (
|
||||
// One dispatch per namespace, so the list identity is the
|
||||
// namespace rather than a position that shifts as cards arrive.
|
||||
<Fragment key={ns}>{renderSlot('settings.plugin.item', {}, { entryKey: ns })}</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
: loaded ? <p className={css.empty}>{t('empty')}</p> : null}
|
||||
{rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : (
|
||||
<>
|
||||
<div className={css.tabs} role="tablist" aria-label={t('tabs')}>
|
||||
{rows.map((row, index) => {
|
||||
const selected = row.id === active
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
ref={(element) => { tabRefs.current[index] = element }}
|
||||
id={`${tabsId}-tab-${row.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
className={css.tab}
|
||||
aria-selected={selected}
|
||||
aria-controls={`${tabsId}-panel-${row.id}`}
|
||||
data-active={selected ? 'true' : undefined}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => { setActiveId(row.id) }}
|
||||
onKeyDown={(event) => {
|
||||
let nextIndex: number
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': nextIndex = (index + 1) % rows.length; break
|
||||
case 'ArrowLeft': nextIndex = (index - 1 + rows.length) % rows.length; break
|
||||
case 'Home': nextIndex = 0; break
|
||||
case 'End': nextIndex = rows.length - 1; break
|
||||
default: return
|
||||
}
|
||||
event.preventDefault()
|
||||
const nextRow = rows[nextIndex] as PluginSettingsTabRow
|
||||
const nextTab = tabRefs.current[nextIndex] as HTMLButtonElement
|
||||
setActiveId(nextRow.id)
|
||||
nextTab.focus()
|
||||
}}
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{rows
|
||||
.filter(row => row.id === active || visitedIds.has(row.id))
|
||||
.map((row) => {
|
||||
const selected = row.id === active
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
id={`${tabsId}-panel-${row.id}`}
|
||||
className={css.panel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${tabsId}-tab-${row.id}`}
|
||||
hidden={!selected}
|
||||
>
|
||||
{renderSlot('settings.plugins.tab', {}, { only: row.id })}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Plugin configuration section and card copy. */
|
||||
/** Plugins section, configurable-tab, and card copy. */
|
||||
'settings.pluginConfig': PluginConfigKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* Plugin configuration surface, browser half — one settings section holding
|
||||
* an expandable card per Host plugin whose configuration a user owns.
|
||||
* Plugins settings surface, browser half — one section whose feature-owned
|
||||
* tabs include configurable Host plugin cards and read-only inventory.
|
||||
*
|
||||
* The section owns no knowledge of any namespace's meaning: it declares the
|
||||
* `settings.plugin.item` slot, reads which namespaces the Host serves, and
|
||||
* dispatches one key per namespace, so a plugin that ships a browser half
|
||||
* contributes its own card under its own namespace and owns its controls. The
|
||||
* three cards this package registers are the host-plane sections this
|
||||
* repository ships; each binds its namespace through the client settings
|
||||
* scope, which keeps them unaware of one another.
|
||||
* The section declares `settings.plugins.tab`; its own `configurable` tab then
|
||||
* declares `settings.plugin.item` and renders whatever cards were registered
|
||||
* into it. The three cards this package ships are the host-plane sections the
|
||||
* deployment already exposes; each binds its namespace through the client
|
||||
* settings scope, which keeps them unaware of one another and of other tabs.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -19,26 +17,30 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// through the service, never a value import (client bundle purity gate).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { AgentLoopCard } from './AgentLoopCard.tsx'
|
||||
import { BashCard } from './BashCard.tsx'
|
||||
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
|
||||
import { PluginConfigSection } from './PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionInjected, PluginSettingsTabRow } from './PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from './WebSearchCard.tsx'
|
||||
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts'
|
||||
import { BASH_NS, BashCardController } from './bash-store.ts'
|
||||
import { PluginConfigSectionController } from './section-store.ts'
|
||||
import { ConfigurablePluginsTabController } from './tab-store.ts'
|
||||
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type { PluginConfigSectionProps } from './PluginConfigSection.tsx'
|
||||
export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
|
||||
export type { ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
|
||||
export type { ConfigurablePluginsTabFace, ConfigurablePluginsTabState } from './tab-store.ts'
|
||||
export type { PluginCardProps } from './PluginCard.tsx'
|
||||
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
|
||||
export type { FieldProps } from './fields.tsx'
|
||||
export type {
|
||||
CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell,
|
||||
} from './card-store.ts'
|
||||
export type { PluginConfigSectionFace, PluginConfigSectionState } from './section-store.ts'
|
||||
export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-store.ts'
|
||||
export type { BashCardFace, BashCardState } from './bash-store.ts'
|
||||
export type { WebSearchCardFace, WebSearchCardState } from './web-search-store.ts'
|
||||
@@ -61,8 +63,6 @@ export function apply(ctx: ClientContext): void {
|
||||
const bash = new BashCardController(ctx.settingsScope.bind({ namespace: BASH_NS }))
|
||||
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
|
||||
const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api)
|
||||
const section = new PluginConfigSectionController(api, () => ctx.slots.entries('settings.plugin.item'))
|
||||
ctx.effect(() => () => { section.dispose() }, 'ui-plugin-config: section directory')
|
||||
|
||||
// The credential a card reports is not part of any settings section, so its
|
||||
// scope publishes nothing when one is written. This is the only signal that
|
||||
@@ -75,31 +75,83 @@ export function apply(ctx: ClientContext): void {
|
||||
// Which namespaces the Host serves is a registration fact the wire does not
|
||||
// announce, so the directory re-reads on the two signals that can carry a
|
||||
// changed composition: a settings document commit and a reconnect.
|
||||
const configurable = new ConfigurablePluginsTabController(
|
||||
api, () => ctx.slots.entries('settings.plugin.item'))
|
||||
ctx.effect(() => () => { configurable.dispose() }, 'ui-plugin-config: tab directory')
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('settings/document-updated', () => { void section.load() }),
|
||||
() => ctx.remote.$on('settings/document-updated', () => { void configurable.load() }),
|
||||
'ui-plugin-config: served-namespace invalidations',
|
||||
)
|
||||
ctx.effect(
|
||||
() => ctx.on('connection/reset', () => { void section.load() }),
|
||||
() => ctx.on('connection/reset', () => { void configurable.load() }),
|
||||
'ui-plugin-config: served-namespace reconnect',
|
||||
)
|
||||
// A card registered after the first read joins the list without a wire call.
|
||||
ctx.effect(
|
||||
() => ctx.slots.subscribe('settings.plugin.item', () => { section.refresh() }),
|
||||
() => ctx.slots.subscribe('settings.plugin.item', () => { configurable.refresh() }),
|
||||
'ui-plugin-config: card ledger',
|
||||
)
|
||||
void section.load()
|
||||
void configurable.load()
|
||||
|
||||
let tabsVersion = -1
|
||||
let tabsRevision = -1
|
||||
let tabs: readonly PluginSettingsTabRow[] = []
|
||||
const sectionInjected = (): PluginConfigSectionInjected => ({
|
||||
hooks: {
|
||||
tabs: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.plugins.tab')
|
||||
const revision = ctx.locale.getSnapshot().revision
|
||||
if (version !== tabsVersion || revision !== tabsRevision) {
|
||||
tabsVersion = version
|
||||
tabsRevision = revision
|
||||
tabs = ctx.slots.entries('settings.plugins.tab')
|
||||
.map(entry => ({
|
||||
/* v8 ignore next -- list-slot registration requires id */
|
||||
id: entry.options.id ?? '',
|
||||
order: entry.options.order ?? 0,
|
||||
label: resolveSlotLabel(entry.options.label) ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return tabs
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
const offLedger = ctx.slots.subscribe('settings.plugins.tab', listener)
|
||||
const offLocale = ctx.locale.subscribe(listener)
|
||||
return () => {
|
||||
offLedger()
|
||||
offLocale()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// This package owns the one Plugins navigation entry and the tab chrome;
|
||||
// feature plugins contribute pages without competing for Settings nav rows.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'plugins',
|
||||
order: 30,
|
||||
order: 15,
|
||||
label: () => t('nav'),
|
||||
locale: NS,
|
||||
inject: () => section.inject(),
|
||||
children: { 'settings.plugin.item': { kind: 'keyed', scope: 'root' } },
|
||||
inject: sectionInjected,
|
||||
children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
|
||||
}, PluginConfigSection))
|
||||
|
||||
// The existing configuration page is one ordinary tab. It keeps ownership
|
||||
// of the card slot and the three shipped card contributions below.
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'configurable',
|
||||
order: 0,
|
||||
label: () => t('configurableTab'),
|
||||
locale: NS,
|
||||
inject: () => configurable.inject(),
|
||||
children: { 'settings.plugin.item': { kind: 'keyed', scope: 'root' } },
|
||||
}, ConfigurablePluginsTab))
|
||||
|
||||
ctx.slots.inject('settings.plugin.item', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type PluginConfigKey =
|
||||
| 'nav' | 'title' | 'intro' | 'empty'
|
||||
| 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty'
|
||||
| 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
|
||||
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
|
||||
| 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
|
||||
@@ -14,9 +14,11 @@ export type PluginConfigKey =
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<PluginConfigKey, string> = {
|
||||
nav: 'Plugin config',
|
||||
title: 'Plugin configuration',
|
||||
intro: 'Configure the plugins this deployment installed.',
|
||||
nav: 'Plugins',
|
||||
title: 'Plugins',
|
||||
intro: 'Configure and inspect the plugins installed in this deployment.',
|
||||
tabs: 'Plugin views',
|
||||
configurableTab: 'Plugin configuration',
|
||||
empty: 'This deployment exposes no plugin settings.',
|
||||
overridden: 'Overridden',
|
||||
reset: 'Reset to default',
|
||||
@@ -53,9 +55,11 @@ export const en: Record<PluginConfigKey, string> = {
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<PluginConfigKey, string> = {
|
||||
nav: '插件配置',
|
||||
title: '插件配置',
|
||||
intro: '配置本部署已安装的插件。',
|
||||
nav: '插件',
|
||||
title: '插件',
|
||||
intro: '配置和查看本部署已安装的插件。',
|
||||
tabs: '插件视图',
|
||||
configurableTab: '插件配置',
|
||||
empty: '本部署没有开放任何插件设置。',
|
||||
overridden: '已覆盖',
|
||||
reset: '恢复默认',
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* The `settings.plugin.item` slot type — one plugin's card inside the plugin
|
||||
* configuration section, keyed by the settings namespace the card edits.
|
||||
* Options: `key` (the namespace). A card draws its own internals; the section
|
||||
* only decides which namespaces to dispatch and stacks what comes back.
|
||||
* The `settings.plugin.item` slot type — one plugin's card inside the
|
||||
* configurable-plugins tab, keyed by the settings namespace the card edits.
|
||||
* Options: `key` (the namespace). A card draws its own internals; the tab only
|
||||
* decides which namespaces to dispatch and stacks what comes back.
|
||||
*
|
||||
* Keying on the namespace is what lets a plugin distributed outside this
|
||||
* repository contribute a card: it registers its own settings namespace on the
|
||||
* Host and its own card under that key in the browser, and the section pairs
|
||||
* the two without ever learning what the namespace means.
|
||||
* Host and its own card under that key in the browser, and the tab pairs the
|
||||
* two without ever learning what the namespace means.
|
||||
*
|
||||
* TYPE HOME RATIONALE: the section declares this slot at runtime, and a plugin
|
||||
* TYPE HOME RATIONALE: the tab declares this slot at runtime, and a plugin
|
||||
* registering its own card already depends on this package for the slot's
|
||||
* declaration. The type therefore lives with its declarer.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The plugin configuration section's card list.
|
||||
* The configurable-plugins tab's card list.
|
||||
*
|
||||
* The section dispatches its slot by settings namespace, so what it renders is
|
||||
* The tab dispatches its slot by settings namespace, so what it renders is
|
||||
* the intersection of two ledgers: the namespaces the Host serves and the
|
||||
* cards registered into `settings.plugin.item`. A served namespace no card
|
||||
* claims renders nothing — another surface owns it, or this deployment ships
|
||||
@@ -15,7 +15,7 @@ import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** What the section renders. */
|
||||
export interface PluginConfigSectionState {
|
||||
export interface ConfigurablePluginsTabState {
|
||||
/**
|
||||
* Whether the Host has answered once. The empty line waits for it: an
|
||||
* unanswered read is not the same statement as "this deployment configures
|
||||
@@ -34,17 +34,17 @@ export interface PluginConfigSectionState {
|
||||
namespaces: string[]
|
||||
}
|
||||
|
||||
/** The registration-side face the section's slot entry injects. */
|
||||
export interface PluginConfigSectionFace {
|
||||
/** The registration-side face the tab's slot entry injects. */
|
||||
export interface ConfigurablePluginsTabFace {
|
||||
hooks: {
|
||||
/** Section snapshot bound by the renderer as usePluginConfigSection. */
|
||||
pluginConfigSection: SnapshotStore<PluginConfigSectionState>
|
||||
configurablePlugins: SnapshotStore<ConfigurablePluginsTabState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the served namespaces and pairs them with the cards that claim them. */
|
||||
export class PluginConfigSectionController {
|
||||
private readonly store = createSnapshotStore<PluginConfigSectionState>({ loaded: false, namespaces: [] })
|
||||
export class ConfigurablePluginsTabController {
|
||||
private readonly store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded: false, namespaces: [] })
|
||||
/** Last Host answer; kept so a slot mutation republishes without a wire read. */
|
||||
private served: readonly string[] = []
|
||||
private loaded = false
|
||||
@@ -76,7 +76,7 @@ export class PluginConfigSectionController {
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
// The section keeps the namespaces it last knew; the next invalidation
|
||||
// The tab keeps the namespaces it last knew; the next invalidation
|
||||
// or reconnect reads again.
|
||||
return
|
||||
}
|
||||
@@ -99,11 +99,11 @@ export class PluginConfigSectionController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the section's slot registration injects.
|
||||
* @returns the section's snapshot source.
|
||||
* Build the face the tab's slot registration injects.
|
||||
* @returns the tab's snapshot source.
|
||||
*/
|
||||
inject(): PluginConfigSectionFace {
|
||||
return { hooks: { pluginConfigSection: this.store } }
|
||||
inject(): ConfigurablePluginsTabFace {
|
||||
return { hooks: { configurablePlugins: this.store } }
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Plugin configuration surface, node half. The empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* settings section through exports["./client"], discovered from the
|
||||
* Plugins settings surface, node half. The empty apply exists so the plugin
|
||||
* appears in the host cordis.yml / Loader; the browser half owns the section
|
||||
* and its configurable tab through exports["./client"], discovered from the
|
||||
* package.json dsh.client declaration. Every section this page edits is owned
|
||||
* by the Host plugin that registered it, so this package registers no
|
||||
* namespace of its own.
|
||||
|
||||
@@ -8,6 +8,9 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
|
||||
import type {
|
||||
ConfigurablePluginsTabFace, PluginConfigSectionInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-plugin-config/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
@@ -65,19 +68,56 @@ describe('ui-plugin-config apply', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers the section and declares the per-plugin card slot', async () => {
|
||||
it('registers one Plugins section and declares the tab and card slots', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
|
||||
expect(section.options).toMatchObject({ id: 'plugins', order: 15 })
|
||||
// The nav label is a locale-following thunk; owners resolve it at read time.
|
||||
expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
|
||||
expect(resolveSlotLabel(section.options.label)).toBe('插件')
|
||||
expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
const tab = slots.entries('settings.plugins.tab')[0]!
|
||||
expect(tab.options).toMatchObject({ id: 'configurable', order: 0 })
|
||||
expect(resolveSlotLabel(tab.options.label)).toBe('插件配置')
|
||||
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
|
||||
})
|
||||
|
||||
|
||||
it('injects a live tab projection, the card directory, and one business face per card', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
const sectionFace = (section.inject as unknown as () => PluginConfigSectionInjected)()
|
||||
const initialTabs = sectionFace.hooks.tabs.getSnapshot()
|
||||
expect(initialTabs).toEqual([
|
||||
{ id: 'configurable', order: 0, label: '插件配置' },
|
||||
])
|
||||
expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs)
|
||||
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = sectionFace.hooks.tabs.subscribe(listener)
|
||||
slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null)
|
||||
expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([
|
||||
{ id: 'configurable', order: 0, label: '插件配置' },
|
||||
{ id: 'plain', order: 0, label: '' },
|
||||
])
|
||||
unsubscribe()
|
||||
|
||||
const tab = slots.entries('settings.plugins.tab')[0]!
|
||||
const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
|
||||
expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins'])
|
||||
for (const entry of slots.entries('settings.plugin.item')) {
|
||||
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
|
||||
// Each card injects exactly one snapshot store plus its own actions.
|
||||
expect(Object.keys(face.hooks)).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('keys each card it ships on the settings namespace that card edits', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
@@ -95,30 +135,17 @@ describe('ui-plugin-config apply', () => {
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
const face = (section as { inject?: () => unknown })
|
||||
.inject?.() as { hooks: { pluginConfigSection: { getSnapshot: () => { namespaces: string[] } } } }
|
||||
const tab = slots.entries('settings.plugins.tab')[0]!
|
||||
const face = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.pluginConfigSection.getSnapshot().namespaces)
|
||||
expect(face.hooks.configurablePlugins.getSnapshot().namespaces)
|
||||
.toEqual(['agent-loop', 'web-search-deepseek'])
|
||||
})
|
||||
})
|
||||
|
||||
it('injects one business face per card', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
for (const entry of slots.entries('settings.plugin.item')) {
|
||||
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
|
||||
// Each card injects exactly one snapshot store plus its own actions.
|
||||
expect(Object.keys(face.hooks)).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('re-reads the served namespaces when the Host commits a settings document', async () => {
|
||||
// Which namespaces the Host serves is a registration fact the wire never
|
||||
// announces on its own, so the section rides the invalidation that can
|
||||
// announces on its own, so the tab rides the invalidation that can
|
||||
// accompany a changed composition.
|
||||
const { ctx, slots, describeSettings } = await bench(['bash'])
|
||||
declareRoot(slots)
|
||||
@@ -189,6 +216,7 @@ describe('ui-plugin-config apply', () => {
|
||||
await fiber.dispose()
|
||||
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(slots.spec('settings.plugins.tab')).toBeUndefined()
|
||||
expect(slots.spec('settings.plugin.item')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,14 +13,16 @@ import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
|
||||
import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
|
||||
import { BashCard } from '../src/client/BashCard.tsx'
|
||||
import type { BashCardProps } from '../src/client/BashCard.tsx'
|
||||
import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx'
|
||||
import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
|
||||
import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionProps, PluginSettingsTabRow } from '../src/client/PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
|
||||
import type { BashCardState } from '../src/client/bash-store.ts'
|
||||
import type { CardFieldState, CardShell } from '../src/client/card-store.ts'
|
||||
import type { PluginConfigSectionState } from '../src/client/section-store.ts'
|
||||
import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts'
|
||||
import type { WebSearchCardState } from '../src/client/web-search-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
@@ -47,22 +49,33 @@ function cardActions() {
|
||||
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
|
||||
}
|
||||
|
||||
function renderSection(rows: readonly PluginSettingsTabRow[]) {
|
||||
const props = {
|
||||
t,
|
||||
useTabs: (selector: (value: readonly PluginSettingsTabRow[]) => unknown) => selector(rows),
|
||||
renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
|
||||
<span>{options.only}</span>
|
||||
),
|
||||
} as unknown as PluginConfigSectionProps
|
||||
render(<PluginConfigSection {...props} />)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the section over the namespaces it was told to dispatch, with `cards`
|
||||
* Render the tab over the namespaces it was told to dispatch, with `cards`
|
||||
* standing in for the slot ledger: a key it names renders that text, and one
|
||||
* it does not renders nothing, exactly as an unclaimed key does.
|
||||
*/
|
||||
function renderSection(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
|
||||
const store = createSnapshotStore<PluginConfigSectionState>({ loaded, namespaces })
|
||||
function renderConfigurable(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
|
||||
const store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded, namespaces })
|
||||
const props = {
|
||||
t,
|
||||
usePluginConfigSection: bindSnapshotSelector(store),
|
||||
useConfigurablePlugins: bindSnapshotSelector(store),
|
||||
renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
|
||||
const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
|
||||
return card === undefined ? null : <li>{card}</li>
|
||||
},
|
||||
} as unknown as PluginConfigSectionProps
|
||||
render(<PluginConfigSection {...props} />)
|
||||
} as unknown as ConfigurablePluginsTabProps
|
||||
render(<ConfigurablePluginsTab {...props} />)
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
@@ -79,8 +92,78 @@ function renderBash(state: Partial<BashCardState> = {}) {
|
||||
}
|
||||
|
||||
describe('PluginConfigSection', () => {
|
||||
it('says so when no plugin contributed a tab', () => {
|
||||
renderSection([])
|
||||
|
||||
expect(screen.getByText(en.empty)).toBeTruthy()
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults to the first ordered tab and mounts another only after selection', () => {
|
||||
renderSection([
|
||||
{ id: 'configurable', order: 0, label: en.configurableTab },
|
||||
{ id: 'all', order: 10, label: 'Plugin list' },
|
||||
])
|
||||
|
||||
const configurable = screen.getByRole('tab', { name: en.configurableTab })
|
||||
const all = screen.getByRole('tab', { name: 'Plugin list' })
|
||||
expect(configurable.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('configurable')).toBeTruthy()
|
||||
expect(screen.queryByText('all')).toBeNull()
|
||||
|
||||
fireEvent.click(all)
|
||||
expect(all.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('all')).toBeTruthy()
|
||||
expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
|
||||
|
||||
fireEvent.click(configurable)
|
||||
expect(configurable.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
|
||||
})
|
||||
|
||||
it('leads with its own heading and intro', () => {
|
||||
renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }])
|
||||
|
||||
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
||||
expect(screen.getByText(en.intro)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('moves focus and selection with standard horizontal tab keys', () => {
|
||||
renderSection([
|
||||
{ id: 'configurable', order: 0, label: en.configurableTab },
|
||||
{ id: 'all', order: 10, label: 'Plugin list' },
|
||||
{ id: 'diagnostics', order: 20, label: 'Diagnostics' },
|
||||
])
|
||||
|
||||
const configurable = screen.getByRole('tab', { name: en.configurableTab })
|
||||
const all = screen.getByRole('tab', { name: 'Plugin list' })
|
||||
const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' })
|
||||
expect(configurable.getAttribute('tabindex')).toBe('0')
|
||||
expect(all.getAttribute('tabindex')).toBe('-1')
|
||||
|
||||
configurable.focus()
|
||||
fireEvent.keyDown(configurable, { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(all)
|
||||
expect(all.getAttribute('aria-selected')).toBe('true')
|
||||
|
||||
fireEvent.keyDown(all, { key: 'End' })
|
||||
expect(document.activeElement).toBe(diagnostics)
|
||||
fireEvent.keyDown(diagnostics, { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(configurable)
|
||||
fireEvent.keyDown(configurable, { key: 'ArrowLeft' })
|
||||
expect(document.activeElement).toBe(diagnostics)
|
||||
fireEvent.keyDown(diagnostics, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(configurable)
|
||||
|
||||
fireEvent.keyDown(configurable, { key: 'Escape' })
|
||||
expect(document.activeElement).toBe(configurable)
|
||||
expect(configurable.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigurablePluginsTab', () => {
|
||||
it('says so when no plugin contributed a card', () => {
|
||||
renderSection([], { bash: 'shell' })
|
||||
renderConfigurable([], { bash: 'shell' })
|
||||
|
||||
expect(screen.getByText(en.empty)).toBeTruthy()
|
||||
expect(screen.queryByText('shell')).toBeNull()
|
||||
@@ -89,25 +172,17 @@ describe('PluginConfigSection', () => {
|
||||
it('withholds the empty line until the Host has answered once', () => {
|
||||
// An unanswered read is not the statement that this deployment configures
|
||||
// no plugin; saying it anyway would flash a wrong answer on every open.
|
||||
renderSection([], { bash: 'shell' }, false)
|
||||
renderConfigurable([], { bash: 'shell' }, false)
|
||||
|
||||
expect(screen.queryByText(en.empty)).toBeNull()
|
||||
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('dispatches one card per namespace, keyed by it', () => {
|
||||
renderSection(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
|
||||
renderConfigurable(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
|
||||
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
|
||||
expect(screen.queryByText(en.empty)).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with its own heading and intro', () => {
|
||||
renderSection(['bash'], { bash: 'shell' })
|
||||
|
||||
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
||||
expect(screen.getByText(en.intro)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCard', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie
|
||||
import { CardForm, numberField, textField } from '../src/client/card-store.ts'
|
||||
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts'
|
||||
import { BashCardController, type BashSettings } from '../src/client/bash-store.ts'
|
||||
import { PluginConfigSectionController } from '../src/client/section-store.ts'
|
||||
import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
|
||||
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts'
|
||||
|
||||
/** Make the stub behave like a Host that accepts every write. */
|
||||
@@ -381,7 +381,7 @@ describe('AgentLoopCardController', () => {
|
||||
})
|
||||
|
||||
describe('WebSearchCardController', () => {
|
||||
it('reads the credential state for the reference the section names', async () => {
|
||||
it('reads the credential state for the reference the tab names', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
@@ -461,7 +461,7 @@ describe('WebSearchCardController', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('addresses the reference the section declares rather than the default', async () => {
|
||||
it('addresses the reference the tab declares rather than the default', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
@@ -540,7 +540,7 @@ describe('WebSearchCardController', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('PluginConfigSectionController', () => {
|
||||
describe('ConfigurablePluginsTabController', () => {
|
||||
function settingsApi(namespaces: string[]) {
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 's-1' as never,
|
||||
@@ -565,59 +565,59 @@ describe('PluginConfigSectionController', () => {
|
||||
|
||||
it('dispatches the served namespaces a card claims, in card registration order', async () => {
|
||||
const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
|
||||
const controller = new PluginConfigSectionController(settings.api, () => ledger('agent-loop', 'bash'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('agent-loop', 'bash'))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// ui-theme is served but claimed by no card here — another surface owns
|
||||
// it. The order is the cards', not the Host's: plugin activation can
|
||||
// reorder the description between boots.
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces)
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces)
|
||||
.toEqual(['agent-loop', 'bash'])
|
||||
})
|
||||
|
||||
it('never dispatches a card whose namespace this deployment does not serve', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash', 'web-search-deepseek'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'web-search-deepseek'))
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
})
|
||||
|
||||
it('takes a card registered after the read without asking the Host again', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
let entries = ledger()
|
||||
const controller = new PluginConfigSectionController(settings.api, () => entries)
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => entries)
|
||||
await controller.load()
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual([])
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
|
||||
|
||||
entries = ledger('bash')
|
||||
controller.refresh()
|
||||
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
expect(settings.describe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the namespaces it knew when a read fails', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
|
||||
await controller.load()
|
||||
settings.describe.mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
})
|
||||
|
||||
it('publishes nothing once disposed, and never claims it was answered', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
|
||||
|
||||
controller.dispose()
|
||||
await controller.load()
|
||||
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot())
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
|
||||
.toEqual({ loaded: false, namespaces: [] })
|
||||
expect(settings.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -625,27 +625,27 @@ describe('PluginConfigSectionController', () => {
|
||||
it('ignores a slot-ledger change that arrives after disposal', async () => {
|
||||
const settings = settingsApi(['bash'])
|
||||
let entries = ledger()
|
||||
const controller = new PluginConfigSectionController(settings.api, () => entries)
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => entries)
|
||||
await controller.load()
|
||||
|
||||
controller.dispose()
|
||||
entries = ledger('bash')
|
||||
controller.refresh()
|
||||
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual([])
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a read a newer one superseded', async () => {
|
||||
// The section re-reads on every settings-document invalidation, so a slow
|
||||
// first answer must not overwrite the newer one that already landed.
|
||||
const settings = settingsApi(['bash'])
|
||||
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash', 'agent-loop'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'agent-loop'))
|
||||
const slow = Promise.withResolvers<unknown>()
|
||||
settings.describe.mockReturnValueOnce(slow.promise as never)
|
||||
const stale = controller.load()
|
||||
|
||||
await controller.load()
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
slow.resolve({
|
||||
rpcId: 's-0',
|
||||
result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [
|
||||
@@ -654,16 +654,16 @@ describe('PluginConfigSectionController', () => {
|
||||
})
|
||||
await stale
|
||||
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
|
||||
})
|
||||
|
||||
it('reports the Host answered even when it serves nothing this section shows', async () => {
|
||||
it('reports the Host answered even when it serves nothing this tab shows', async () => {
|
||||
const settings = settingsApi(['ui-theme'])
|
||||
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
|
||||
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.inject().hooks.pluginConfigSection.getSnapshot())
|
||||
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
|
||||
.toEqual({ loaded: true, namespaces: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-plugins/README.md
|
||||
README.md: bb487d5e2cbd34406d83867997ede4d70b190d70
|
||||
README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9
|
||||
README.md: a663665cc2d7ce26a2724da9aecc25f51515327a
|
||||
README.zh.md: 852ecbe6546f76a76fc6d9d3a8bb891539b0844f
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
|
||||
Read-only **Plugin list** tab for Web Settings. The browser plugin registers one localized `settings.plugins.tab` contribution with id `all`; the Plugins section owns the navigation entry and tab chrome. It performs no Remote read during plugin activation. Selecting the tab for the first time mounts it and lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
|
||||
|
||||
The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store.
|
||||
The tab renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the short module name as its title and a small effective-enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals its Loader-tree entry id without a redundant field label, followed by the effective configuration and, for enabled entries, Cordis status. Disabled entries omit the redundant unmounted runtime state. The entry id remains the React key, disclosure identity, detail value, and an additional search target; it is never classified by string shape. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -16,5 +16,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot.
|
||||
- **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one.
|
||||
- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。
|
||||
Web 设置中的只读**插件列表**标签页。浏览器插件注册一个 id 为 `all` 的本地化 `settings.plugins.tab` 贡献;“插件”分区拥有导航入口与标签栏。插件激活期间不会读取 Remote;首次选择该标签页时才挂载组件,并通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。
|
||||
|
||||
页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。
|
||||
该标签页以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用模块短名称作为标题,以小标签表示有效启停状态;已启用的条目还会以彩色圆点表示根 fiber 状态。展开卡片后会直接展示 Loader 树条目 id,不附加重复的字段标题,并列出有效配置状态;已启用的条目还会列出 Cordis 状态,已停用的条目则省略重复的“未挂载”运行状态。条目 id 仍作为 React key、展开标识、详情值与额外的搜索目标;代码不按字符串形状对它分类。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -16,5 +16,5 @@ Web 设置中的只读“插件”分区。浏览器插件在“模型”之后
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。
|
||||
- **每次 Settings 挂载或重试只读取一份快照** —— 标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。
|
||||
- **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plugins",
|
||||
"description": "Read-only Cordis Loader plugin inventory in Web settings",
|
||||
"description": "Read-only Cordis Loader inventory tab in Web Plugins settings",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
|
||||
@@ -7,19 +7,12 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.heading h2,
|
||||
.catalogHeading h3,
|
||||
.status,
|
||||
.failure p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status,
|
||||
.failure {
|
||||
font-size: 13px;
|
||||
|
||||
@@ -19,7 +19,7 @@ type PluginFiberPhase = PluginInventoryEntry['fiberPhase']
|
||||
|
||||
/** Full component props assembled by the Settings slot renderer. */
|
||||
export type PluginSettingsSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
PropsRuntime<'settings.plugins.tab'>
|
||||
& PropsLocale<'settings.plugins'>
|
||||
& InjectFace<PluginSettingsSectionInjected>
|
||||
|
||||
@@ -62,7 +62,7 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean
|
||||
|
||||
/** Render the read-only current Loader inventory. */
|
||||
export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode {
|
||||
const titleId = useId()
|
||||
const catalogId = useId()
|
||||
const [request, setRequest] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [expanded, setExpanded] = useState<PluginInventoryEntry['entryId'] | null>(null)
|
||||
@@ -97,10 +97,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.section} aria-labelledby={titleId} aria-busy={state.status === 'loading'}>
|
||||
<header className={css.heading}>
|
||||
<h2 id={titleId}>{t('title')}</h2>
|
||||
</header>
|
||||
<div className={css.section} aria-busy={state.status === 'loading'}>
|
||||
{state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
|
||||
{state.status === 'error' ? (
|
||||
<div className={css.failure}>
|
||||
@@ -134,8 +131,9 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
|
||||
{filteredEntries.map((entry) => {
|
||||
const status = phaseLabel(entry.fiberPhase, t)
|
||||
const title = moduleShortName(entry.moduleName)
|
||||
const configuration = t(entry.enabled ? 'enabledTag' : 'disabledTag')
|
||||
const open = expanded === entry.entryId
|
||||
const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}`
|
||||
const detailId = `${catalogId}-details-${encodeURIComponent(entry.entryId)}`
|
||||
return (
|
||||
<li
|
||||
className={css.card}
|
||||
@@ -148,22 +146,24 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={detailId}
|
||||
aria-label={`${title}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`}
|
||||
aria-label={entry.enabled ? `${title}, ${status}, ${configuration}` : `${title}, ${configuration}`}
|
||||
onClick={() => {
|
||||
setExpanded(current => current === entry.entryId ? null : entry.entryId)
|
||||
}}
|
||||
>
|
||||
<strong className={css.cardTitle} title={entry.moduleName}>{title}</strong>
|
||||
<span className={css.cardTrailing}>
|
||||
<span
|
||||
className={css.statusDot}
|
||||
data-phase={entry.fiberPhase ?? 'unobserved'}
|
||||
role="img"
|
||||
aria-label={status}
|
||||
title={status}
|
||||
/>
|
||||
{entry.enabled ? (
|
||||
<span
|
||||
className={css.statusDot}
|
||||
data-phase={entry.fiberPhase ?? 'unobserved'}
|
||||
role="img"
|
||||
aria-label={status}
|
||||
title={status}
|
||||
/>
|
||||
) : null}
|
||||
<span className={css.configTag} data-enabled={entry.enabled ? 'true' : 'false'}>
|
||||
{t(entry.enabled ? 'enabledTag' : 'disabledTag')}
|
||||
{configuration}
|
||||
</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
|
||||
</span>
|
||||
@@ -174,12 +174,14 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
|
||||
<dl className={css.details}>
|
||||
<div>
|
||||
<dt>{t('configuration')}</dt>
|
||||
<dd>{t(entry.enabled ? 'enabledTag' : 'disabledTag')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('cordis')}</dt>
|
||||
<dd>{status}</dd>
|
||||
<dd>{configuration}</dd>
|
||||
</div>
|
||||
{entry.enabled ? (
|
||||
<div>
|
||||
<dt>{t('cordis')}</dt>
|
||||
<dd>{status}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -190,6 +192,6 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const NS = 'settings.plugins'
|
||||
/** Services required by the Settings registration and generated Remote face. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
|
||||
|
||||
/** Register the lazy plugin inventory page below Models in Settings. */
|
||||
/** Contribute the lazy inventory tab to the Plugins settings section. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries')
|
||||
|
||||
@@ -36,11 +36,11 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
const injected = (): PluginSettingsSectionInjected => ({ list })
|
||||
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'plugin-inventory',
|
||||
order: 15,
|
||||
label: () => t('nav'),
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'all',
|
||||
order: 10,
|
||||
label: () => t('tab'),
|
||||
locale: NS,
|
||||
inject: injected,
|
||||
}, PluginSettingsSection))
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
/** Simplified Chinese dictionary and key source of truth. */
|
||||
export const zh = {
|
||||
nav: '插件',
|
||||
title: '插件',
|
||||
tab: '插件列表',
|
||||
loading: '正在读取插件…',
|
||||
error: '暂时无法读取插件。',
|
||||
retry: '重试',
|
||||
@@ -28,8 +27,7 @@ export type PluginsKey = keyof typeof zh
|
||||
|
||||
/** English dictionary checked against the Chinese key set. */
|
||||
export const en = {
|
||||
nav: 'Plugins',
|
||||
title: 'Plugins',
|
||||
tab: 'Plugin list',
|
||||
loading: 'Reading plugins…',
|
||||
error: 'Plugins are temporarily unavailable.',
|
||||
retry: 'Retry',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
/** Host loader entry for the inventory-tab browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the plugin settings section. */
|
||||
/** Host plugin body — no host-side behavior for the plugin inventory tab. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -38,7 +38,7 @@ async function bench() {
|
||||
function declare(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'root',
|
||||
children: { 'settings.section': { kind: 'list', scope: 'root' } },
|
||||
children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
@@ -47,16 +47,16 @@ describe('ui-plugins browser plugin', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
|
||||
})
|
||||
|
||||
it('registers a localized section without reading the Remote eagerly', async () => {
|
||||
it('registers a localized tab without reading the Remote eagerly', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const entry = b.slots.entries('settings.section')[0]!
|
||||
const entry = b.slots.entries('settings.plugins.tab')[0]!
|
||||
expect(entry.component).toBe(PluginSettingsSection)
|
||||
expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 })
|
||||
expect(entry.options).toMatchObject({ id: 'all', order: 10 })
|
||||
expect(entry.locale).toBe(NS)
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('插件')
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('插件列表')
|
||||
expect(b.list).not.toHaveBeenCalled()
|
||||
|
||||
const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)()
|
||||
@@ -71,22 +71,22 @@ describe('ui-plugins browser plugin', () => {
|
||||
const b = await bench()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
|
||||
|
||||
const stop = declare(b.slots)
|
||||
await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) })
|
||||
await vi.waitFor(() => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(1) })
|
||||
b.locale.setLocale('en')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.plugins.tab')[0]!.options.label)).toBe('Plugin list')
|
||||
|
||||
stop()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
|
||||
declare(b.slots)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection)
|
||||
expect(b.slots.entries('settings.plugins.tab')[0]?.component).toBe(PluginSettingsSection)
|
||||
})
|
||||
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
|
||||
expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -12,16 +12,12 @@ afterEach(cleanup)
|
||||
|
||||
type Snapshot = Awaited<ReturnType<PluginSettingsSectionInjected['list']>>
|
||||
const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t']
|
||||
const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never
|
||||
|
||||
function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps {
|
||||
return {
|
||||
close: vi.fn(),
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
t,
|
||||
list,
|
||||
}
|
||||
} as PluginSettingsSectionProps
|
||||
}
|
||||
|
||||
const SNAPSHOT = {
|
||||
@@ -31,12 +27,13 @@ const SNAPSHOT = {
|
||||
{ entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' },
|
||||
{ entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' },
|
||||
{ entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' },
|
||||
{ entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null },
|
||||
{ entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null },
|
||||
],
|
||||
} as unknown as Snapshot
|
||||
|
||||
describe('PluginSettingsSection', () => {
|
||||
it('renders searchable two-column-card semantics with dots and tags', async () => {
|
||||
it('renders runtime status only for enabled plugins', async () => {
|
||||
const deferred = Promise.withResolvers<Snapshot>()
|
||||
const list = vi.fn(() => deferred.promise)
|
||||
const view = render(<PluginSettingsSection {...props(list)} />)
|
||||
@@ -46,9 +43,9 @@ describe('PluginSettingsSection', () => {
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6')
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(6)
|
||||
expect(screen.getAllByText(en.enabledTag)).toHaveLength(5)
|
||||
expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7')
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(7)
|
||||
expect(screen.getAllByText(en.enabledTag)).toHaveLength(6)
|
||||
expect(screen.getByText(en.disabledTag)).toBeTruthy()
|
||||
for (const value of [
|
||||
'Mounted',
|
||||
@@ -75,8 +72,10 @@ describe('PluginSettingsSection', () => {
|
||||
target: { value: 'disabled-entry' },
|
||||
})
|
||||
expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Disabled' }))
|
||||
expect(screen.getAllByText(en.disabledTag)).toHaveLength(2)
|
||||
expect(screen.queryByText(en.cordis)).toBeNull()
|
||||
expect(screen.queryByText(en.unobserved)).toBeNull()
|
||||
})
|
||||
|
||||
it('filters by module name or Loader entry id', async () => {
|
||||
|
||||
@@ -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-settings/README.md
|
||||
README.md: 8bf085fd02e3c76148674065bb4f5708e9a6e8d8
|
||||
README.zh.md: f7b6f18809c64be6830ea23c3968e9af70b70c41
|
||||
README.md: 950585c4957cd59fe3a38dc37cdd4084f7c5541c
|
||||
README.zh.md: dce8dbf5c8e0939142fed8a3df84c47acd7c8a1e
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
|
||||
The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), `settings.plugins.tab` (feature-owned pages inside the Plugins section), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
|
||||
|
||||
The plugin injects nothing and waits for nothing: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。
|
||||
设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。
|
||||
|
||||
该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。
|
||||
|
||||
|
||||
@@ -51,6 +51,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* item registrant; the shell neither declares nor renders it.)
|
||||
*/
|
||||
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
|
||||
/**
|
||||
* One page inside the Plugins settings section. The section owner renders
|
||||
* localized entry labels as tabs and mounts each contribution inside its
|
||||
* corresponding tab panel. Options: `id` (tab key), `order` (tab order),
|
||||
* and `label` (registrant-localized tab text). Declared at runtime by the
|
||||
* feature that owns the Plugins section; the type lives here so inventory
|
||||
* and configuration plugins collaborate without depending on one another.
|
||||
*/
|
||||
'settings.plugins.tab': { kind: 'list'; scope: 'root'; owner: SettingsPluginsTabOwnerProps }
|
||||
/**
|
||||
* Root-scoped onboarding steps contributed by settings features. The
|
||||
* shell mounts one ordered step at a time; the active registrant either
|
||||
@@ -83,6 +92,12 @@ export interface SettingsGeneralItemOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of a Plugins tab (the section supplies nothing). */
|
||||
export interface SettingsPluginsTabOwnerProps {
|
||||
/** Marker field: tab owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of the trigger content seat: the sidebar column state. */
|
||||
export interface SettingsTriggerOwnerProps {
|
||||
/** Whether the sidebar renders wide content (false = 56px rail, icon only). */
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SettingsScopeService } from './settings-scope.ts'
|
||||
|
||||
export type {
|
||||
SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
|
||||
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
export { SettingsScopeController, SettingsScopeService } from './settings-scope.ts'
|
||||
|
||||
|
||||
@@ -52,28 +52,28 @@ describe('PluginInventoryService', () => {
|
||||
})
|
||||
await ctx.loader.create({ name: 'cordis:active', group: true })
|
||||
|
||||
expect(inventory.list()).toEqual({
|
||||
entries: [
|
||||
{
|
||||
entryId: activeId,
|
||||
moduleName: 'cordis:active',
|
||||
enabled: true,
|
||||
fiberPhase: 'active',
|
||||
},
|
||||
{
|
||||
entryId: pendingId,
|
||||
moduleName: 'cordis:pending',
|
||||
enabled: true,
|
||||
fiberPhase: 'pending',
|
||||
},
|
||||
{
|
||||
entryId: disabledId,
|
||||
moduleName: 'cordis:not-installed',
|
||||
enabled: false,
|
||||
fiberPhase: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
const snapshot = inventory.list()
|
||||
expect(snapshot.entries).toHaveLength(3)
|
||||
expect(snapshot.entries).toEqual(expect.arrayContaining([
|
||||
{
|
||||
entryId: activeId,
|
||||
moduleName: 'cordis:active',
|
||||
enabled: true,
|
||||
fiberPhase: 'active',
|
||||
},
|
||||
{
|
||||
entryId: pendingId,
|
||||
moduleName: 'cordis:pending',
|
||||
enabled: true,
|
||||
fiberPhase: 'pending',
|
||||
},
|
||||
{
|
||||
entryId: disabledId,
|
||||
moduleName: 'cordis:not-installed',
|
||||
enabled: false,
|
||||
fiberPhase: null,
|
||||
},
|
||||
]))
|
||||
|
||||
await ctx.loader.update(activeId, { disabled: true })
|
||||
expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { MessageId, type CallId } from './brand.ts'
|
||||
import { deepFreeze } from './call-config.ts'
|
||||
import type { ContentBlock, ToolResultBlock } from './types.ts'
|
||||
import type { ContentBlock, StreamChunk, ToolResultBlock } from './types.ts'
|
||||
|
||||
/** Provider/model identity and adapter-private replay data for an assistant message. */
|
||||
export interface AssistantProvenance {
|
||||
@@ -239,3 +239,23 @@ export function createToolResultMessage(input: ToolResultMessageInput): ToolResu
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stream chunk carries visible model output (the first-token
|
||||
* boundary shared by client step timing and the whole-log sessionStats
|
||||
* projection). Empty deltas (heartbeats, empty tool-call frames) do not count
|
||||
* as a first token.
|
||||
* @param chunk - the stream chunk to test.
|
||||
* @returns true when the chunk contains a non-empty text/reasoning/tool delta.
|
||||
*/
|
||||
export function isTokenDelta(chunk: StreamChunk): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return 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/session/README.md
|
||||
README.md: 586d1be0286a0de935b0b08313e6965452b85376
|
||||
README.zh.md: 7947468ce2107c41134a83c6985108db00edc430
|
||||
README.md: 64aa8e4fdf1e85d74dfa0a77803a04e881b547c4
|
||||
README.zh.md: 14ff59fa137c74202c31dadc243a5c21fc15ab47
|
||||
|
||||
@@ -25,6 +25,7 @@ Serves current, log-derived per-session state to client carriers.
|
||||
|---|---|---|
|
||||
| [`session-projection/`](session-projection/README.md) | Defines and drives session projection units | `ctx.sessionProjections` |
|
||||
| [`session-projection-cache/`](session-projection-cache/README.md) | Persists and restores projection checkpoints | `ctx.sessionProjectionCache` |
|
||||
| [`session-stats/`](session-stats/README.md) | Serves whole-log conversation counts and wall times (`sessionStats` unit) | registers on `ctx.sessionProjections` |
|
||||
|
||||
## Titles
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|---|---|---|
|
||||
| [`session-projection/`](session-projection/README.md) | 定义并驱动会话投影单元 | `ctx.sessionProjections` |
|
||||
| [`session-projection-cache/`](session-projection-cache/README.md) | 持久化并恢复投影检查点 | `ctx.sessionProjectionCache` |
|
||||
| [`session-stats/`](session-stats/README.md) | 提供全日志会话计数与墙钟时间(`sessionStats` 单元) | 注册到 `ctx.sessionProjections` |
|
||||
|
||||
## 标题
|
||||
|
||||
|
||||
6
packages/session/session-stats/README.i18n.yaml
Normal file
6
packages/session/session-stats/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session/session-stats/README.md
|
||||
README.md: 81b0de17e335b67936afd6fb11f15411beee3b76
|
||||
README.zh.md: 606628ea09bc34203a5374e49db62c1646293846
|
||||
39
packages/session/session-stats/README.md
Normal file
39
packages/session/session-stats/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-session-stats
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Function plugin registering the `sessionStats` projection unit: whole-log conversation figures — turn/step counts and the LLM, tool, first-token, and decode wall times — folded from step boundaries, stream chunks, tool pairs, and assembled assistant messages, and served through the session-projection seam (registry snapshot, change feed, and every projection carrier: history tail page, `session/projection` push frames, session list rows). Clients render full-session figures that paging and compaction cannot change; the reference consumer is the web chat stats strip, whose window fold mirrors these field names as its no-unit fallback.
|
||||
|
||||
## Fold semantics
|
||||
|
||||
- `steps` counts `step/end` events. The agent loop appends exactly one per entered step, in a `finally`, so completed, failed, cancelled, and max-tokens steps all count. Counting assembled assistant messages instead would overcount max-tokens usage-host messages (empty content, excluded from the surface) and undercount cancelled steps (aborted before the message assembles).
|
||||
- `turns` counts distinct turns carrying at least one closed step; rejected or empty turns (closed with no step) are uncounted. Turn numbers are host-assigned and monotonic per session, so the fold keeps only the last counted turn.
|
||||
- `llmMs` sums `step/start` → `assistant/message` per step that assembled a message (retry waits inside the step are model time, as in the window fold).
|
||||
- `ttftMs`/`ttftSteps` sum and count `step/start` → first non-empty delta chunk; the first attempt's boundary survives an in-step `llm/retry` (window `resetForRetry` parity).
|
||||
- `decodeMs`/`decodeTokens` sum first token → assembled message and the provider-reported output tokens, only over steps carrying both.
|
||||
- `toolMs` sums `tool/call` → `tool/result` pairs matched by callId; unresolved calls are dropped at `turn/end` (results land within their turn).
|
||||
- Every field is 0 until its first contributing event. A composed registry always serves the key, so clients read the value, never key presence.
|
||||
|
||||
## Composition
|
||||
|
||||
```yaml
|
||||
- id: session-stats
|
||||
name: '@deepseek-ai/dsh-session-stats'
|
||||
```
|
||||
|
||||
Injects `sessionProjections` — the plugin's whole purpose; in assemblies without the registry the fiber stays pending and nothing registers.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the plugin only computes a client-facing read model of already-logged session events and touches no prompt, message, schema, stream, or tool result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; the plugin never assembles or sends provider requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Steps count work attempted, not visible output** — a step that failed before producing any visible content still closed with `step/end` and counts; a step interrupted by a crash counts after the session reloads, when crash recovery appends its synthetic `step/end` (`interruptedTurnClosers` in dsh-session).
|
||||
- **A cancelled step is counted but untimed** — no assistant message assembles, so its partial stream time enters no wall-time figure, matching the window fold's untimed interrupted node; a max-tokens usage-host message conversely contributes model time the surface does not show.
|
||||
- **Counts are log-scoped, not surface-scoped** — steps whose messages were later compacted away stay counted; the figures describe the whole session, not the current model-visible surface.
|
||||
- **Mounted only in the web-app bundle** — other assemblies serve no `sessionStats` key, and their consumers fall back to window-scoped counting (the web stats strip's fallback path).
|
||||
39
packages/session/session-stats/README.zh.md
Normal file
39
packages/session/session-stats/README.zh.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-session-stats
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
注册 `sessionStats` projection 单元的函数插件:从步边界、流式 chunk、工具配对与已组装的 assistant 消息折叠出全日志会话数字——轮/步计数以及 LLM、工具、首 token、解码墙钟时间——经 session-projection 缝对外提供(registry 快照、变更流,以及每一个 projection 载体:history 尾页、`session/projection` 推送帧、会话列表行)。客户端由此渲染分页与压缩都无法改变的全会话数字;参考消费者是 Web 聊天统计条,其窗口折叠以相同字段名充当无单元时的回退。
|
||||
|
||||
## 折叠语义
|
||||
|
||||
- `steps` 统计 `step/end` 事件。agent loop 对每个进入的步在 `finally` 中恰好追加一条,因此完成、失败、取消、max-tokens 的步全部计入。若改按已组装的 assistant 消息计数,则会多算 max-tokens 的 usage 宿主消息(空内容、被排除在 surface 之外),并少算被取消的步(在消息组装前已中止)。
|
||||
- `turns` 统计含至少一个已关闭步的不同 turn;被拒绝或空轮(未进入任何步即关闭)不计。turn 号由宿主分配、按会话单调递增,因此折叠只需保留最近计入的 turn。
|
||||
- `llmMs` 按步累加 `step/start` → `assistant/message`(组装出消息的步;步内重试的等待与窗口折叠一样计入模型时间)。
|
||||
- `ttftMs`/`ttftSteps` 累加并统计 `step/start` → 首个非空 delta chunk;首次尝试的边界在步内 `llm/retry` 后保留(与窗口 `resetForRetry` 对齐)。
|
||||
- `decodeMs`/`decodeTokens` 累加首 token → 已组装消息的时长与提供方上报的输出 token,仅统计两者兼备的步。
|
||||
- `toolMs` 按 callId 配对累加 `tool/call` → `tool/result`;未解决的调用在 `turn/end` 时丢弃(结果总在其轮内落地)。
|
||||
- 每个字段在首个贡献事件之前均为 0。已装配的 registry 恒提供该键,客户端读取值本身,而非键的存在性。
|
||||
|
||||
## 组合
|
||||
|
||||
```yaml
|
||||
- id: session-stats
|
||||
name: '@deepseek-ai/dsh-session-stats'
|
||||
```
|
||||
|
||||
注入 `sessionProjections`——这是插件的全部用途;在没有 registry 的装配中 fiber 保持挂起,不注册任何内容。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为插件只计算面向客户端的、由已写入日志的会话事件派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;插件从不组装或发送提供方请求。
|
||||
|
||||
## 已知局限与延后工作
|
||||
|
||||
- **步数统计的是已发生的工作,而非可见输出**——在产生任何可见内容前就失败的步仍以 `step/end` 关闭并计入;被崩溃打断的步在会话重新加载后计入,届时崩溃恢复为其补写合成的 `step/end`(dsh-session 的 `interruptedTurnClosers`)。
|
||||
- **被取消的步计数但不计时**——没有组装出 assistant 消息,其部分流式时间不进入任何墙钟数字,与窗口折叠的无计时 interrupted 节点一致;反之 max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。
|
||||
- **计数是日志口径,不是 surface 口径**——消息后来被压缩掉的步仍然计入;数字描述整个会话,而非当前模型可见 surface。
|
||||
- **仅挂载于 web-app bundle**——其他装配不提供 `sessionStats` 键,其消费者回退到窗口口径计数(Web 统计条的回退路径)。
|
||||
62
packages/session/session-stats/package.json
Normal file
62
packages/session/session-stats/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-stats",
|
||||
"description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/session/session-stats"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
10
packages/session/session-stats/src/client.ts
Normal file
10
packages/session/session-stats/src/client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Client-namespace projection of the session-stats domain: a pure re-export
|
||||
* of the package's types outlet. Client code imports ONLY the client
|
||||
* namespace (repo discipline), so `./client` projects the same single-source
|
||||
* content `./types` serves to host consumers — zero duplication.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-stats/client
|
||||
*/
|
||||
|
||||
export type * from './types.ts'
|
||||
29
packages/session/session-stats/src/index.ts
Normal file
29
packages/session/session-stats/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Function plugin registering the `sessionStats` projection unit: whole-log
|
||||
* turn/step counts and LLM/tool/first-token/decode wall times served through
|
||||
* the session-projection seam (registry snapshot, change feed, and every
|
||||
* projection carrier), so clients render full-session figures that paging and
|
||||
* compaction cannot change. The plugin owns only the fold; delivery is the
|
||||
* seam's.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-stats
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { sessionStatsProjectionDefinition } from './projection.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'session-stats'
|
||||
/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
|
||||
export const inject = ['sessionProjections']
|
||||
|
||||
/**
|
||||
* Register the `sessionStats` unit; the registration is an effect on this
|
||||
* plugin's fiber, so unloading removes the key.
|
||||
* @param ctx - registrant context carrying the projection registry.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.sessionProjections.register(sessionStatsProjectionDefinition)
|
||||
}
|
||||
35
packages/session/session-stats/src/invariant.ts
Normal file
35
packages/session/session-stats/src/invariant.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-stats`.
|
||||
* @module @deepseek-ai/dsh-session-stats/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-stats'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-stats-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the package owns a single pure projection fold whose
|
||||
* wire payload is schema-validated by the projection registry at every
|
||||
* snapshot and change-feed emission, and the event relations the fold relies
|
||||
* on (`step/end` exactly once per entered step, monotonic host-assigned turn
|
||||
* numbers, chunk and tool events carrying their step coordinates and call
|
||||
* ids) are owned and runtime-checked by dsh-agent-loop and the session
|
||||
* surface, not here.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
183
packages/session/session-stats/src/projection.ts
Normal file
183
packages/session/session-stats/src/projection.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* The `sessionStats` projection unit: a pure fold of step boundaries, stream
|
||||
* chunks, tool pairs, and assembled assistant messages into whole-log counts
|
||||
* and wall times.
|
||||
*
|
||||
* `step/end` — not `assistant/message` — is the counted step event because it
|
||||
* is the step lifecycle authority: the loop appends exactly one per entered
|
||||
* step, in a `finally`, so completed, failed, cancelled, and max-tokens steps
|
||||
* all land one. Counting assembled assistant messages instead would overcount
|
||||
* max-tokens usage-host messages (empty content, excluded from the surface)
|
||||
* and undercount cancelled steps (aborted before the message assembles).
|
||||
*
|
||||
* The wall-time folds mirror the client window fold field by field
|
||||
* (`deriveStats` in dsh-client-ui-conversation, that fold's whole-window
|
||||
* fallback role): model time is `step/start` → `assistant/message`, first
|
||||
* token is the first non-empty delta chunk and survives an in-step
|
||||
* `llm/retry`, decode spans first token → assembled message on steps that
|
||||
* also report output tokens, and tool time pairs `tool/call` → `tool/result`
|
||||
* by callId. A cancelled step assembles no message, so its partial stream
|
||||
* time stays uncounted in every time figure — matching the window, which
|
||||
* renders it as an untimed interrupted node.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-stats/projection
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
/** Accumulated whole-log figures (the view is exactly these totals). */
|
||||
interface SessionStatsTotals {
|
||||
/** Distinct turns with at least one closed step so far. */
|
||||
turns: number
|
||||
/** Closed steps so far. */
|
||||
steps: number
|
||||
/** Summed model wall time over message-assembling steps, ms. */
|
||||
llmMs: number
|
||||
/** Summed matched tool call→result wall time, ms. */
|
||||
toolMs: number
|
||||
/** Summed first-token latency over `ttftSteps`, ms. */
|
||||
ttftMs: number
|
||||
/** Steps carrying a recorded first token. */
|
||||
ttftSteps: number
|
||||
/** Summed decode wall time over usage-reporting steps, ms. */
|
||||
decodeMs: number
|
||||
/** Summed provider output tokens over the same steps. */
|
||||
decodeTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold state: the totals plus the in-flight boundaries they accrue from.
|
||||
* Turn numbers are host-assigned and monotonic per session, so a single
|
||||
* `lastTurn` slot decides "first closed step of a new turn"; the state is
|
||||
* plain JSON per the unit contract (persisted-cache precondition).
|
||||
*/
|
||||
interface SessionStatsState extends SessionStatsTotals {
|
||||
/** Turn of the last counted `step/end`; null before the first. */
|
||||
lastTurn: number | null
|
||||
/** The open step's boundary facts; null outside a step or after its message assembled. */
|
||||
openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null
|
||||
/** Dispatch times of tool calls whose result has not landed, by callId. */
|
||||
pendingCalls: Record<string, number>
|
||||
}
|
||||
|
||||
const sessionStatsSchema = z.object({
|
||||
turns: z.number().int().nonnegative(),
|
||||
steps: z.number().int().nonnegative(),
|
||||
llmMs: z.number().nonnegative(),
|
||||
toolMs: z.number().nonnegative(),
|
||||
ttftMs: z.number().nonnegative(),
|
||||
ttftSteps: z.number().int().nonnegative(),
|
||||
decodeMs: z.number().nonnegative(),
|
||||
decodeTokens: z.number().nonnegative(),
|
||||
}).strict()
|
||||
|
||||
/**
|
||||
* Provider-reported completion tokens, guarded the way the window fold guards
|
||||
* node usage.
|
||||
* @param usage - the assistant/message event's optional usage record.
|
||||
* @returns the output-token count, or null when unreported or invalid.
|
||||
*/
|
||||
function usageOutputTokens(usage: unknown): number | null {
|
||||
if (typeof usage !== 'object' || usage === null) return null
|
||||
const value = (usage as { outputTokens?: unknown }).outputTokens
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
/** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
|
||||
export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState> = {
|
||||
key: 'sessionStats',
|
||||
schema: sessionStatsSchema,
|
||||
init: () => ({
|
||||
turns: 0,
|
||||
steps: 0,
|
||||
llmMs: 0,
|
||||
toolMs: 0,
|
||||
ttftMs: 0,
|
||||
ttftSteps: 0,
|
||||
decodeMs: 0,
|
||||
decodeTokens: 0,
|
||||
lastTurn: null,
|
||||
openStep: null,
|
||||
pendingCalls: {},
|
||||
}),
|
||||
apply: (state, event) => {
|
||||
// Every uninteresting event returns the same reference (Object.is gates the change feed).
|
||||
switch (event.type) {
|
||||
case 'step/start':
|
||||
return {
|
||||
...state,
|
||||
openStep: { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null },
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const open = state.openStep
|
||||
if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
|
||||
if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk)) return state
|
||||
return { ...state, openStep: { ...open, firstTokenTime: event.time } }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const open = state.openStep
|
||||
if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
|
||||
// One assembled message per step: closing the boundary means a
|
||||
// defensive duplicate cannot accrue twice.
|
||||
const next: SessionStatsState = {
|
||||
...state,
|
||||
llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
|
||||
openStep: null,
|
||||
}
|
||||
if (open.firstTokenTime !== null) {
|
||||
next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime)
|
||||
next.ttftSteps += 1
|
||||
const outputTokens = usageOutputTokens(event.data.usage)
|
||||
if (outputTokens !== null) {
|
||||
next.decodeMs += Math.max(0, event.time - open.firstTokenTime)
|
||||
next.decodeTokens += outputTokens
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
case 'tool/call':
|
||||
return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } }
|
||||
case 'tool/result': {
|
||||
// Own-key check: callId is provider-minted (model/tool JSON boundary),
|
||||
// so a prototype property name ('constructor', 'toString') on a result
|
||||
// with no recorded call must read as unmatched, not as an inherited
|
||||
// function that would poison toolMs with NaN.
|
||||
const callId = event.data.message.source.callId
|
||||
const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : undefined
|
||||
if (dispatched === undefined) return state
|
||||
const pendingCalls = Object.fromEntries(
|
||||
Object.entries(state.pendingCalls).filter(([id]) => id !== callId),
|
||||
)
|
||||
return { ...state, toolMs: state.toolMs + Math.max(0, event.time - dispatched), pendingCalls }
|
||||
}
|
||||
case 'step/end':
|
||||
return {
|
||||
...state,
|
||||
turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
|
||||
steps: state.steps + 1,
|
||||
lastTurn: event.data.turn,
|
||||
openStep: null,
|
||||
}
|
||||
case 'turn/end':
|
||||
// A call whose result never landed belongs to a cancelled or failed
|
||||
// turn; results always land within their turn, so drop the leftovers
|
||||
// instead of growing persisted state forever.
|
||||
return Object.keys(state.pendingCalls).length === 0 ? state : { ...state, pendingCalls: {} }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
},
|
||||
view: state => ({
|
||||
turns: state.turns,
|
||||
steps: state.steps,
|
||||
llmMs: state.llmMs,
|
||||
toolMs: state.toolMs,
|
||||
ttftMs: state.ttftMs,
|
||||
ttftSteps: state.ttftSteps,
|
||||
decodeMs: state.decodeMs,
|
||||
decodeTokens: state.decodeTokens,
|
||||
}),
|
||||
stateVersion: 1,
|
||||
}
|
||||
46
packages/session/session-stats/src/types.ts
Normal file
46
packages/session/session-stats/src/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Pure types of the session-stats domain: the ONE home of the `sessionStats`
|
||||
* projection-key declaration, free of this package's host-side value imports
|
||||
* (cordis context, zod, the llm chunk predicate). Two namespace projections
|
||||
* serve it — `./types` for host consumers, `./client` for client aggregates —
|
||||
* with zero content duplication.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-stats/types
|
||||
*/
|
||||
|
||||
// Marks this file a module so the declaration below AUGMENTS the projection
|
||||
// table instead of declaring an ambient module.
|
||||
export {}
|
||||
|
||||
/**
|
||||
* Whole-log conversation figures, independent of how much history a client
|
||||
* has paged in. Counts and wall times all fold from the complete durable log;
|
||||
* every field is 0 until its first contributing event lands. Field names
|
||||
* mirror the client window fold so an assembly without this unit can fall
|
||||
* back to it wholesale.
|
||||
*/
|
||||
export interface SessionStatsProjection {
|
||||
/** Distinct turns carrying at least one closed step (`step/end`); rejected or empty turns are uncounted. */
|
||||
turns: number
|
||||
/** Closed steps (`step/end` events) — completed, failed, and cancelled steps alike. */
|
||||
steps: number
|
||||
/** Summed model wall time (`step/start` → `assistant/message`) over steps that assembled a message. */
|
||||
llmMs: number
|
||||
/** Summed tool wall time over `tool/call` → `tool/result` pairs matched by callId. */
|
||||
toolMs: number
|
||||
/** Summed first-token latency (`step/start` → first non-empty delta chunk) over `ttftSteps`. */
|
||||
ttftMs: number
|
||||
/** Steps carrying a recorded first token. */
|
||||
ttftSteps: number
|
||||
/** Summed decode wall time (first token → `assistant/message`) over steps that also report output tokens. */
|
||||
decodeMs: number
|
||||
/** Summed provider output tokens over the same decode-timed steps. */
|
||||
decodeTokens: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Whole-log turn/step counts and wall times; see {@link SessionStatsProjection}. */
|
||||
sessionStats: SessionStatsProjection
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* REAL-composition proof: the shipped YAML shape (session + projection
|
||||
* registry + session-stats) boots through the vendored Loader, the function
|
||||
* plugin's namespace survives (no default export), and a full logged turn
|
||||
* serves `{turns: 1, steps: 1}` through the composed registry.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
async function loadYaml(lines: readonly string[]): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-session-stats-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [...lines, ''].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-session', SessionStore],
|
||||
['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
|
||||
['@deepseek-ai/dsh-session-stats', SessionStatsPlugin],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
return context
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
it('loads the shipped session-stats YAML shape and serves whole-log counts', async () => {
|
||||
const loaded = await loadYaml([
|
||||
"- name: '@deepseek-ai/dsh-session'",
|
||||
"- name: '@deepseek-ai/dsh-session-projection'",
|
||||
"- name: '@deepseek-ai/dsh-session-stats'",
|
||||
])
|
||||
|
||||
const unloaded = [...loaded.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
|
||||
const session = loaded.sessions.create(SessionId('composed'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(loaded.sessionProjections.snapshot(session).values.sessionStats)
|
||||
.toMatchObject({ turns: 1, steps: 1 })
|
||||
})
|
||||
|
||||
it('keeps the function-plugin namespace free of a default export', () => {
|
||||
// A default export beside the named form makes the Loader discard the
|
||||
// namespace (postmortem 0001) — pin its absence.
|
||||
expect('default' in SessionStatsPlugin).toBe(false)
|
||||
})
|
||||
})
|
||||
292
packages/session/session-stats/tests/projection.spec.ts
Normal file
292
packages/session/session-stats/tests/projection.spec.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* The `sessionStats` projection unit: mounting the plugin beside the
|
||||
* projection registry serves whole-log counts and wall times folded from step
|
||||
* boundaries, chunks, tool pairs, and assembled messages; compositions
|
||||
* without the registry are unaffected; unmounting the plugin removes the key
|
||||
* (HMR safety). The two counting regressions pinned here are the reasons the
|
||||
* fold counts step boundaries instead of assistant messages: a cancelled step
|
||||
* never assembles a message but still counts, and a max-tokens usage-host
|
||||
* message (empty content) adds no extra step. Wall-time math runs against the
|
||||
* exported definition directly, where event times are controlled.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
|
||||
import { sessionStatsProjectionDefinition } from '@deepseek-ai/dsh-session-stats/src/projection.ts'
|
||||
import type { SessionStatsProjection } from '@deepseek-ai/dsh-session-stats/types'
|
||||
|
||||
async function harness(withStatsPlugin: boolean): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
if (withStatsPlugin) await ctx.plugin(SessionStatsPlugin)
|
||||
return { ctx, session: ctx.sessions.create(SessionId('counted')) }
|
||||
}
|
||||
|
||||
/** Close one step; returns the counted `step/end` seq. */
|
||||
function closeStep(session: Session, turn: number, step: number): number {
|
||||
session.append('step/start', { turn, step })
|
||||
return session.append('step/end', { turn, step }).seq
|
||||
}
|
||||
|
||||
/** Append the max-tokens usage-host shape: an assistant/message with empty content. */
|
||||
function appendEmptyAssistantMessage(session: Session, turn: number, step: number): void {
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||
}
|
||||
|
||||
/** The all-zero projection value plus overrides, for exact fold expectations. */
|
||||
function totals(overrides: Partial<SessionStatsProjection> = {}): SessionStatsProjection {
|
||||
return {
|
||||
turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sessionStats projection unit (registry drive)', () => {
|
||||
it('serves zero figures on the empty log', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
expect(ctx.sessionProjections.snapshot(session).values.sessionStats).toEqual(totals())
|
||||
})
|
||||
|
||||
it('counts distinct turns and closed steps and notifies the change feed with the causing seq', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const changes: { key: string; value: unknown; seq: number }[] = []
|
||||
ctx.sessionProjections.onChanged((_session, key, value, seq) => {
|
||||
changes.push({ key, value, seq })
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const firstSeq = closeStep(session, 1, 1)
|
||||
const secondSeq = closeStep(session, 1, 2)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
const thirdSeq = closeStep(session, 2, 1)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
// Boundary events that carry no figure change (turn/start, empty-prune
|
||||
// turn/end, user input) fold to the same reference and stay silent;
|
||||
// step/start opens a boundary (internal state) and step/end commits the
|
||||
// counts, so each closed step notifies twice with the step/end value last.
|
||||
const counted = changes.filter(change => (change.value as SessionStatsProjection).steps > 0
|
||||
|| change.seq === firstSeq)
|
||||
expect(changes.every(change => change.key === 'sessionStats')).toBe(true)
|
||||
expect(counted.map(change => ({ seq: change.seq, value: change.value }))).toContainEqual(
|
||||
{ seq: firstSeq, value: totals({ turns: 1, steps: 1 }) },
|
||||
)
|
||||
expect(changes.at(-1)).toEqual({ key: 'sessionStats', value: totals({ turns: 2, steps: 3 }), seq: thirdSeq })
|
||||
const snapshot = ctx.sessionProjections.snapshot(session)
|
||||
expect(snapshot.values.sessionStats).toEqual(totals({ turns: 2, steps: 3 }))
|
||||
expect(snapshot.asOfSeq).toBe(session.seq - 1)
|
||||
expect(changes.map(change => change.seq)).toContain(secondSeq)
|
||||
})
|
||||
|
||||
it('does not count a rejected or empty turn that closes with no step', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
|
||||
expect(ctx.sessionProjections.snapshot(session).values.sessionStats).toEqual(totals())
|
||||
})
|
||||
|
||||
it('counts a cancelled step that closed without an assistant message', async () => {
|
||||
// Regression: an aborted stream never assembles assistant/message, but the
|
||||
// loop's finally still appends step/end — the step happened and counts.
|
||||
const { ctx, session } = await harness(true)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
closeStep(session, 1, 1)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'legacy' } } })
|
||||
expect(ctx.sessionProjections.snapshot(session).values.sessionStats)
|
||||
.toMatchObject({ turns: 1, steps: 1 })
|
||||
})
|
||||
|
||||
it('adds no extra step for a max-tokens usage-host assistant message', async () => {
|
||||
// Regression: the empty-content assistant/message exists only to host
|
||||
// usage and is excluded from the surface; the step counts once, from its
|
||||
// step/end, while the message contributes only its model wall time.
|
||||
const { ctx, session } = await harness(true)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
appendEmptyAssistantMessage(session, 1, 1)
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
expect(ctx.sessionProjections.snapshot(session).values.sessionStats)
|
||||
.toMatchObject({ turns: 1, steps: 1, ttftSteps: 0, decodeTokens: 0 })
|
||||
})
|
||||
|
||||
it('folds steps already in the log when the plugin mounts late (lazy cell build)', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
closeStep(session, 1, 1)
|
||||
closeStep(session, 1, 2)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(SessionStatsPlugin)
|
||||
expect(ctx.sessionProjections.snapshot(session).values.sessionStats)
|
||||
.toMatchObject({ turns: 1, steps: 2 })
|
||||
})
|
||||
|
||||
it('has no sessionStats key without the plugin, and drops it when the plugin unloads (HMR safety)', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
expect('sessionStats' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||
const fiber = await ctx.plugin(SessionStatsPlugin)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
closeStep(session, 1, 1)
|
||||
expect(ctx.sessionProjections.snapshot(session).values.sessionStats)
|
||||
.toMatchObject({ turns: 1, steps: 1 })
|
||||
await fiber.dispose()
|
||||
expect('sessionStats' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/** Build one synthetic committed event with a controlled timestamp. */
|
||||
function at(time: number, type: string, data: unknown): SessionEvent {
|
||||
return { type, seq: time, time, data } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
/** Fold a synthetic event list through the definition and view the result. */
|
||||
function fold(events: readonly SessionEvent[]): SessionStatsProjection {
|
||||
const state = events.reduce(
|
||||
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
|
||||
sessionStatsProjectionDefinition.init(),
|
||||
)
|
||||
return sessionStatsProjectionDefinition.view(state)
|
||||
}
|
||||
|
||||
describe('sessionStats wall-time fold (controlled timestamps)', () => {
|
||||
const message = createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'answer' }],
|
||||
source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
})
|
||||
|
||||
it('accrues model, first-token, and decode time from one fully recorded step', () => {
|
||||
expect(fold([
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_800, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }),
|
||||
at(4_800, 'assistant/message', { turn: 1, step: 1, message, usage: { inputTokens: 10, outputTokens: 60 } }),
|
||||
at(4_900, 'step/end', { turn: 1, step: 1 }),
|
||||
])).toEqual(totals({
|
||||
turns: 1, steps: 1, llmMs: 3_800, ttftMs: 800, ttftSteps: 1, decodeMs: 3_000, decodeTokens: 60,
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps the first attempt token boundary across an in-step retry (window resetForRetry parity)', () => {
|
||||
expect(fold([
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_200, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'x' } }),
|
||||
at(2_000, 'llm/retry', { turn: 1, step: 1 }),
|
||||
at(3_000, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'y' } }),
|
||||
at(5_000, 'assistant/message', { turn: 1, step: 1, message }),
|
||||
at(5_100, 'step/end', { turn: 1, step: 1 }),
|
||||
])).toEqual(totals({ turns: 1, steps: 1, llmMs: 4_000, ttftMs: 200, ttftSteps: 1 }))
|
||||
})
|
||||
|
||||
it('ignores empty deltas, non-token chunks, and chunks outside the open step', () => {
|
||||
expect(fold([
|
||||
// Chunk before any step/start: no open boundary.
|
||||
at(500, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'stray' } }),
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_100, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } }),
|
||||
at(1_200, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } }),
|
||||
at(1_300, 'assistant/chunk', { turn: 2, step: 9, chunk: { type: 'text-delta', index: 0, text: 'other' } }),
|
||||
at(1_400, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }),
|
||||
at(2_000, 'assistant/message', { turn: 1, step: 1, message }),
|
||||
at(2_100, 'step/end', { turn: 1, step: 1 }),
|
||||
])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 }))
|
||||
})
|
||||
|
||||
it('leaves a cancelled step untimed: counted by step/end, no assembled message to accrue from', () => {
|
||||
expect(fold([
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_500, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial' } }),
|
||||
at(2_000, 'step/end', { turn: 1, step: 1 }),
|
||||
])).toEqual(totals({ turns: 1, steps: 1 }))
|
||||
})
|
||||
|
||||
it('pairs tool wall time by callId, ignores orphan results, and prunes leftovers at turn/end', () => {
|
||||
const result = (callId: string): unknown =>
|
||||
({ turn: 1, step: 1, message: { source: { kind: 'tool', callId } } })
|
||||
const paired = fold([
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'read', arguments: '{}' }),
|
||||
at(1_200, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'read', arguments: '{}' }),
|
||||
// Out-of-order settlement pairs by id, not adjacency.
|
||||
at(4_200, 'tool/result', result('b')),
|
||||
at(1_600, 'tool/result', result('a')),
|
||||
at(5_000, 'tool/result', result('ghost')),
|
||||
at(5_100, 'step/end', { turn: 1, step: 1 }),
|
||||
])
|
||||
expect(paired).toEqual(totals({ turns: 1, steps: 1, toolMs: 3_500 }))
|
||||
// An unresolved call is dropped at turn/end; a later result cannot pair.
|
||||
const pruned = fold([
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'orphan', name: 'read', arguments: '{}' }),
|
||||
at(2_000, 'step/end', { turn: 1, step: 1 }),
|
||||
at(2_100, 'turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'legacy' } } }),
|
||||
at(9_000, 'tool/result', result('orphan')),
|
||||
])
|
||||
expect(pruned).toEqual(totals({ turns: 1, steps: 1 }))
|
||||
})
|
||||
|
||||
it('pairs only own pendingCalls keys: a prototype-name callId without a recorded call stays unmatched', () => {
|
||||
const result = (callId: string): unknown =>
|
||||
({ turn: 1, step: 1, message: { source: { kind: 'tool', callId } } })
|
||||
// Crash recovery (TOOL_NOT_STARTED) emits results with no preceding
|
||||
// tool/call; a provider-minted callId colliding with an Object prototype
|
||||
// property must read as absent, not as an inherited function that would
|
||||
// fold toolMs to NaN and fail the value schema.
|
||||
expect(fold([
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_500, 'tool/result', result('toString')),
|
||||
at(2_000, 'step/end', { turn: 1, step: 1 }),
|
||||
])).toEqual(totals({ turns: 1, steps: 1 }))
|
||||
// The same name pairs normally once its call is recorded.
|
||||
expect(fold([
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'constructor', name: 'read', arguments: '{}' }),
|
||||
at(1_600, 'tool/result', result('constructor')),
|
||||
at(2_000, 'step/end', { turn: 1, step: 1 }),
|
||||
])).toEqual(totals({ turns: 1, steps: 1, toolMs: 500 }))
|
||||
})
|
||||
|
||||
it('skips decode for an invalid usage report and ignores a duplicate assembled message', () => {
|
||||
const events = [
|
||||
at(1_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_400, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }),
|
||||
// A malformed provider report: guarded like the window fold guards node usage.
|
||||
at(2_000, 'assistant/message', { turn: 1, step: 1, message, usage: { inputTokens: 1, outputTokens: -5 } }),
|
||||
]
|
||||
expect(fold([...events, at(2_100, 'step/end', { turn: 1, step: 1 })]))
|
||||
.toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 }))
|
||||
// The first message closed the step boundary; a defensive duplicate finds
|
||||
// no open step and folds to the same reference.
|
||||
const state = events.reduce(
|
||||
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
|
||||
sessionStatsProjectionDefinition.init(),
|
||||
)
|
||||
expect(sessionStatsProjectionDefinition.apply(
|
||||
state,
|
||||
at(2_050, 'assistant/message', { turn: 1, step: 1, message }),
|
||||
)).toBe(state)
|
||||
})
|
||||
|
||||
it('accrues nothing for unrelated events and clamps negative clock skew to zero', () => {
|
||||
const state = sessionStatsProjectionDefinition.init()
|
||||
const untouched = sessionStatsProjectionDefinition.apply(state, at(1, 'user/message', { content: [] }))
|
||||
expect(untouched).toBe(state)
|
||||
expect(fold([
|
||||
at(2_000, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1_000, 'assistant/message', { turn: 1, step: 1, message }),
|
||||
at(2_100, 'step/end', { turn: 1, step: 1 }),
|
||||
])).toEqual(totals({ turns: 1, steps: 1 }))
|
||||
})
|
||||
})
|
||||
30
packages/session/session-stats/tsconfig.json
Normal file
30
packages/session/session-stats/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../session-projection"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user