Merge remote-tracking branch 'origin/master' into worktree/locale-browser-default

# Conflicts:
#	apps/web/tests/onboarding-deepseek-config.e2e.ts
#	packages/client/ui-settings-general/tests/apply.spec.ts
This commit is contained in:
creatixchu
2026-07-31 16:42:37 +08:00
136 changed files with 3428 additions and 451 deletions

View File

@@ -138,7 +138,34 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
}
/**
* The structured `web_search` result view for fixture turn 66, authored inline
* Read-card sample for the read turn: a WINDOW past an offset, so the line
* numbers start above 1 (the card's gutter keeps the file's own numbering) and
* `totalLines` exceeds the window (the card shows a "showing N of M" note). The
* fixture is client-side and cannot import the read tool, so the structured
* window is authored inline exactly as the tool would project it through
* `presentationMeta`. `lang` is a `ts` hint so the shiki path highlights it.
*/
const READ_SAMPLE_FIRST_LINE = 41
const READ_SAMPLE_SOURCE = [
'export interface ReadBlockProps {',
' label?: string | undefined',
' lines: readonly ReadBlockLine[]',
' totalLines: number',
' lang?: string | undefined',
' maxLines?: number | undefined',
' className?: string | undefined',
'}',
'',
'// A windowed read keeps the file line numbers in the gutter.',
'const marker = "fixture read sample"',
]
const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
const READ_SAMPLE_TOTAL = 180
const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
/**
* The structured `web_search` result view for fixture turn 67, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
@@ -168,7 +195,7 @@ const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'sear
truncated: true,
}
/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
/** The `web_fetch` result view for fixture turn 68, authored inline for the same reason. */
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
@@ -315,8 +342,8 @@ function buildAlphaLog(): SessionEvent[] {
const turn = 64
const callId = `fx-call-${turn}`
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
+ 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
+ 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
+ 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+ 'return { listing, demo }'
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -341,8 +368,8 @@ function buildAlphaLog(): SessionEvent[] {
})
}
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
dispatchPair(2, 'read', { file_path: 'notes/demo.txt' }, 'hello fixture\n')
dispatchPair(3, 'read', { file_path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
push({
type: 'tool/result', surfaceOp: 'append',
data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) },
@@ -350,7 +377,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Turn 65: todo_write sample — the TodoRow toolview in the flow plus the
// Turn 67: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip.
const fixtureTodos = [
{ content: '梳理需求', status: 'completed' },
@@ -371,7 +398,20 @@ function buildAlphaLog(): SessionEvent[] {
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
// Turns 66-67: the web render intent — a web_search whose result view carries
// Turn 66: the read sample — a WINDOW past an offset so the card draws file
// line numbers starting above 1 and a "showing N of M" note (the window is
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
// The render-site fallback ROW SHAPE (a read call on the generic flattened
// path) is covered by the turn 64 run_code read sub-dispatches, which
// session.ts folds with resultView: null; the fallback-row + read-CARD
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
// this fixture. The read render intent is result-side only, so its pending
// call stays a generic `kind: 'read'` card; presentResult carries the
// structured window.
toolTurn(66, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
// Turns 67-68: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
@@ -380,11 +420,11 @@ function buildAlphaLog(): SessionEvent[] {
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
toolTurn(67, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(68, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(69, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -419,6 +459,12 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
card: 'diff', title: `Write ${str(args.path)}`,
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
// A read pending call is a GENERIC card (kind: 'read', a follow-along
// location): the read render intent is result-side only, because a call
// carries no file content until execute returns. The rich read card arrives
// in presentResult.
case 'read':
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
case 'edit':
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
// scattered hunks share one path header and the card draws the `⋯` gap.
@@ -455,6 +501,16 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
// The read result is the structured window the tool projects through
// `presentationMeta`; the fixture authors it inline (it cannot import the
// tool). Keyed on the name because the read pending call is a generic card,
// so `call.card` alone does not distinguish it from edit/write.
if (name === 'read') {
return {
card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES,
totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
}
}
// The web tools keep a generic pending card, so their result card is chosen
// by tool name rather than by the pending card tag: the structured `web` card
// the frontend consumes. The view carries no `content` copy (per the contract

View File

@@ -54,6 +54,7 @@ const PRIVILEGED_METHODS = new Set([
'settings.describe',
'settings.update',
'settings.replace',
'settings.mutate',
'credentials.describe',
'credentials.set',
'credentials.unset',

View File

@@ -107,7 +107,7 @@ describe('connection node half', () => {
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.update', 'settings.replace',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
]) {
const denied = fakeResponse()
@@ -191,7 +191,7 @@ describe('connection node half over a real HTTP server', () => {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
]) {

View File

@@ -20,6 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { readToolview } from './toolviews/read-row.tsx'
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
import { webToolview } from './toolviews/web-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
@@ -320,6 +321,10 @@ export function apply(ctx: Context): void {
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The read row rides the same seam (a product registration, not a sample):
// Read · {path} chrome with the file's read card resident below it.
ctx.plugin(readToolview)
// The write/edit rows ride the same seam: a file-mutation call declares the
// diff render intent, so these rows stack the applied diff card under their
// path-link summary (the terminal card's posture, applied to diffs).

View File

@@ -1,7 +1,8 @@
/* The generic card grows a resident web card under its summary row when the
tool declares the `web` render intent but has no keyed row of its own (the
web_search/web_fetch rows register their own WebRow). A column around the
ToolRow keeps the row's own 24px height. */
/* GenericToolCard resident cards: a read-declaring or web-declaring tool
without its own keyed row (e.g. web_fetch) grows a resident card under its
summary row. A column around the ToolRow keeps the row's own 24px height, so
the read card renders identically to the keyed ReadRow and the web card to
the web_search/web_fetch WebRow. */
.card {
display: flex;
@@ -10,6 +11,7 @@
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read,
.web {
margin: 4px 0 4px 22px;
}

View File

@@ -7,9 +7,10 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14, WebBlock,
IconThinkOutline14, ReadBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
@@ -37,6 +38,7 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const read = readCardModel(block, cwd)
const diff = diffCardModel(block)
const web = webCardModel(block)
// A failing exit status is the terminal card's own error signal (the call
@@ -69,6 +71,18 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
inspect={inspect}
/>
)
// A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
// so the file's read card is resident below the summary row exactly as the
// keyed ReadRow draws it. Only wrap when a card is present, so every other
// tool keeps the bare ToolRow.
if (read !== null) {
return (
<div className={css.card}>
{row}
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
</div>
)
}
// A web-declaring tool without its own keyed row lands here; its card is
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
if (web === null) return row

View File

@@ -0,0 +1,76 @@
/**
* Pure derivation of the read-card props from a frozen call slice: the
* `card:'read'` render intent the read tool declares arrives on the snapshot as
* the settled result node's `resultView`, and this is the one place that turns
* it into what {@link ReadBlock} draws. Both conversation render sites (the chat
* tool row's resident body and the details panel's Output section) call this, so
* the path, lines, total, and language they show are derived once.
*
* The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)):
* a call carries no file content until `execute` returns, so the pending call
* stays a generic card (`kind: 'read'`). A running read therefore has no read
* card, and this returns null for it — the row keeps its args-derived summary
* until the result arrives.
* @module
*/
import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
/**
* Content lines the chat row's resident read body shows before collapsing the
* middle — half the primitive's own default, which the details panel keeps. A
* chat row is a summary surface inside the message flow: the flow must stay
* scannable across many calls, while the details panel is the single-call
* reading surface. A design constant of this UI's row geometry, not a
* deployment choice, so it is fixed here rather than a plugin Config field. The
* same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for
* terminal output.
*/
export const CHAT_READ_MAX_LINES = 8
/**
* The {@link ReadBlock} props this derivation owns. Picked off the primitive's
* props so the two stay in step; `maxLines`/`className` belong to each render
* site.
*/
export type ReadCardModel = Pick<ReadBlockProps, 'label' | 'lines' | 'totalLines' | 'lang'>
/**
* Derive the read-card props for a tool call, or null when this call is not a
* read card and belongs on the generic path.
*
* The read card is result-side only, so only a settled call whose result view
* declares `card:'read'` produces one. Every other case is null — the
* documented generic-card default:
*
* - A running call: it has no result view yet, and a read carries no content at
* call time.
* - A settled call whose result view is not a read card — including a `card`
* value this UI version does not know, which arrives over the wire and cannot
* be trusted to be one of the compiled variants, and the read tool's own
* generic fallback for an error result or a non-envelope body.
*
* The label is the read view's `title` when the tool supplied one (the
* presentation contract's replacement-title rule), otherwise the file path
* relativized to the session workspace so a workspace-rooted absolute path
* displays the same short form the row summary shows.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param sessionCwd - the session workspace root; a workspace-rooted absolute
* path label displays relative to it. Absent leaves the path as authored.
* @returns the read-card props, or null for the generic path.
*/
export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null {
// Running has no result view; a read carries no content until execute returns.
if (!('kind' in block)) return null
const result = block.resultView?.card === 'read' ? block.resultView : null
if (result === null) return null
// Lines arrive frozen off the snapshot; copy into the primitive's own line
// shape so the card never holds a reference into the runtime's cache.
const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
return {
label: result.title ?? relativizeToCwd(result.path, sessionCwd),
lines,
totalLines: result.totalLines,
lang: result.lang,
}
}

View File

@@ -133,8 +133,13 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
others: [],
}
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
function relativizeToCwd(text: string, cwd: string | undefined): string {
/**
* Strip the workspace root from a workspace-rooted absolute path (display only).
* @param text - the path to shorten.
* @param cwd - session workspace root; absent or empty leaves the path unchanged.
* @returns the path relative to the workspace root, or unchanged when it is not rooted there.
*/
export function relativizeToCwd(text: string, cwd: string | undefined): string {
if (cwd === undefined || cwd === '') return text
const root = cwd.replace(/[/\\]+$/, '')
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)

View File

@@ -108,8 +108,9 @@
margin: 0;
}
/* Same rule for the web card: it sits under the section label, so the section
owns the spacing rather than the primitive's own vertical margin. */
/* The read and web cards sit directly under their section label, same as the
terminal card: drop the primitive's standalone vertical margin. */
.read,
.web {
margin: 0;
}

View File

@@ -7,10 +7,11 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock, DiffBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, ReadBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { webCardModel } from '../contract/web-card-model.ts'
@@ -129,7 +130,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* The Output section's body for the selected call. A terminal-card call — a
* shell command's call/result views — renders through the shared TerminalBlock
* at the primitive's own full height allowance, so column-aligned output keeps
* its alignment and scrolls sideways instead of folding. A diff-card call — a
* its alignment and scrolls sideways instead of folding. A read-card call
* renders through the shared ReadBlock at that same full height, so the whole
* returned window is line-numbered and highlighted. A diff-card call — a
* write/edit's applied change — renders through the shared DiffBlock at the same
* full height. A web-card call — a `web_search`/`web_fetch` result — renders
* through WebBlock at its own full source-list allowance. Every other call, and
@@ -153,6 +156,10 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
</>
)
}
const read = readCardModel(material.block, cwd)
// The panel takes the primitive's own default cap, not the row's tighter one:
// it is the single-call reading surface, so the whole window is available.
if (read !== null) return <ReadBlock {...read} className={css.read} />
const diff = diffCardModel(material.block)
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
const web = webCardModel(material.block)

View File

@@ -0,0 +1,119 @@
/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus
the read card the row stacks under its summary line. */
/* Summary line over the read card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row
gives the same executing feedback a running command row does. The leading
read icon stays static (a read has no per-step state to animate); the sweep
is the row-level running signal. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-read-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-read-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -0,0 +1,105 @@
// Read toolview registrant: the keyed toolview hole for the read tool
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow (figma: Read · {path}); the summary is the
// file path as an openable link, exactly as the generic read row draws it.
//
// A read RESULT declares the read render intent, so this row renders the file's
// own line-numbered, syntax-highlighted content through ReadBlock resident
// below its summary line — the same posture BashRow gives a terminal card. The
// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the
// block's own default of 16) with the block's internal expander keeping a long
// read from taking over the message flow. A running read (no result yet) and a
// non-read result both render the summary row alone. The read intent is
// result-side only, so there is no running-state read card to draw.
import type { Context } from 'cordis'
import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './read-row.module.css'
/** Leading-slot state substitution: the tool icon yields to the state dot
* (error = red, interrupted = amber). Running keeps the icon. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconBrowseOutline16 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
* read card resident below it. The summary path is an openable host link when
* the row names a single file; the card's copy and expand controls plus that
* link are the row's only interactions (tool rows are not details-panel
* targets).
*/
export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) {
// Session workspace root: the read view's path relativizes against it (a
// workspace-rooted absolute path shows its short form), which the pure
// presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const model = toolRowModel(toolName, block, cwd)
const read = readCardModel(block, cwd)
const status = stateStatus(model.state)
const filePath = model.filePath
return (
<div className={css.card}>
{/* jscpd:ignore-start — the summary-line chrome (leading, status, title,
sep, path-link/summary) is the shared ToolRow row shape every keyed
toolview draws; extracting it into one component is a separate change
tracked for all rows at once, not this read-card PR. */}
<div className={css.root} data-variant="read" data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{filePath !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={() => { openFile(filePath) }}
>
{model.summary}
</button>
) : (
<span className={css.summary}>{model.summary}</span>
)}
</div>
{/* jscpd:ignore-end */}
{read !== null && (
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
)}
</div>
)
}
/**
* The read row as a plain registrant plugin. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and with
* it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export const readToolview = {
name: 'read-toolview',
inject: ['slots', 'conversation'],
/**
* Register the read row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow)
},
}

View File

@@ -88,14 +88,14 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// file-mutation registrant claims both write and edit for the diff card; the
// web rows register one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()

View File

@@ -0,0 +1,293 @@
// @vitest-environment jsdom
// The read render intent on the web side: the pure readCardModel derivation
// over the settled result view, and both conversation render sites that consume
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
// each with the read card resident under the summary) and the details panel's
// Output section. Also pins the keyed 'read' toolview registration.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { Context } from 'cordis'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** The chat-view locale seat: this package's namespace over the common fallback. */
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
// The read tool's real schema key is `file_path`; the top-level read samples
// use it so the row exercises a production-shaped call. `web_fetch` (below) has
// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`.
const ARGS = '{"file_path":"src/a.ts","offset":41}'
const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}'
/** The read block's rendered content cells, one string per row (highlighting
* breaks a line across token spans, so match on the row's textContent). */
function contentTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('[data-read] [class^="_content_"]')].map(cell => cell.textContent ?? '')
}
/** Three windowed lines starting at file line 41 (a read past an offset). */
const sampleLines = [
{ number: 41, text: 'export const a = 1' },
{ number: 42, text: 'export const b = 2' },
{ number: 43, text: 'export const c = 3' },
]
/** The read tool's own result view for a settled file read. */
const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>): ToolResultView => ({
card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over,
})
describe('readCardModel', () => {
it('derives the card from a settled read result view', () => {
expect(readCardModel(settled())).toEqual({
label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts',
})
})
it('copies the lines into the primitive shape rather than aliasing the frozen slice', () => {
const model = readCardModel(settled())
expect(model?.lines).toEqual(sampleLines)
expect(model?.lines).not.toBe(sampleLines)
expect(model?.lines[0]).not.toBe(sampleLines[0])
})
it('takes the result view\'s replacement title over the relativized path', () => {
// The presentation contract defines a result title as REPLACING the pending
// one, so a tool that supplies a label wins over the path here.
expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label)
.toBe('Read (head) src/a.ts')
})
it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
// A workspace-rooted absolute path shows its short form.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
.toBe('src/a.ts')
// A path outside the workspace stays as authored.
expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label)
.toBe('/srv/other.ts')
// With no session cwd there is nothing to relativize against.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label)
.toBe('/w/app/src/a.ts')
})
it('carries an omitted language through as undefined', () => {
const noLang = resultRead()
delete (noLang as { lang?: string }).lang
expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined()
})
it('returns null for a running read: the read intent is result-side only', () => {
// A read carries no content until execute returns, so the pending call is a
// generic card and there is no read card to draw yet.
expect(readCardModel(running())).toBeNull()
})
it('returns null for every non-read settled call: no view, generic view, unknown card', () => {
expect(readCardModel(settled({ resultView: null }))).toBeNull()
expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart' } as unknown as ToolResultView
expect(readCardModel(settled({ resultView: future }))).toBeNull()
})
})
describe('GenericToolCard read body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
})
it('renders the read card resident under the summary, capped tighter than the panel', () => {
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
// web_fetch lands on the read variant without its own keyed row, so the
// fallback card owns the resident read block.
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
// The gutter keeps the file's own line numbers.
expect(view.getByText('41')).toBeTruthy()
})
it('a non-read tool renders the bare row with no read card', () => {
const view = render(<GenericToolCard {...({
callId: 'c1', toolName: 'echo', block: settled({
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
}), openFile: vi.fn(), t,
})} />)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('a running read renders the summary row alone (no result view yet)', () => {
const view = render(<GenericToolCard {...ownerProps(running({ name: 'web_fetch' }))} />)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})
describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
})
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
it('renders the file path summary and the resident read card', () => {
const view = render(<ReadRow {...rowProps(settled())} />)
expect(view.getByText('Read')).toBeTruthy()
// The path appears twice: the row summary link and the card's banner label.
expect(view.getAllByText('src/a.ts').length).toBe(2)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
})
it('the path summary opens the file through the host', () => {
const openFile = vi.fn()
const view = render(<ReadRow {...{ ...rowProps(settled()), openFile }} />)
fireEvent.click(view.getByRole('button', { name: 'src/a.ts' }))
// The row derives the file path from args; the chat view resolves it against
// the cwd before this callback opens it, so the arg path is what arrives.
expect(openFile).toHaveBeenCalledWith('src/a.ts')
})
it('a running read renders the summary row alone, and its state', () => {
const view = render(<ReadRow {...rowProps(running())} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('running')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an error read result shows the error state and no read card', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: { card: 'generic' }, isError: true,
content: [{ type: 'text', text: 'ENOENT' }],
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an interrupted read shows the stopped state', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: null, isError: true, error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped')
})
it('registers under the read key of the keyed toolview slot', () => {
const registered: { name: unknown; key?: unknown }[] = []
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
readToolview.apply(ctx)
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }])
expect(readToolview.inject).toContain('conversation')
})
})
describe('DetailsPanel Output section (read)', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
t={t}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
}
}
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' }
it('renders the read card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
const view = mount(snapshot({
nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })],
}), target)
expect(view.getByText(/"file_path"/)).toBeTruthy()
expect(view.container.querySelector('[data-read]')).not.toBeNull()
// The panel takes the primitive's own default cap (16), not the row's.
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
expect(contentTexts(view.container)).toContain('row-0')
})
it('a non-read result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settled({
callView: null, resultView: null,
content: [{ type: 'text', text: 'plain result' }],
})],
}), target)
expect(view.container.querySelector('[data-read]')).toBeNull()
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result')
})
it('a running read keeps the 运行中… placeholder (no result view)', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.getByText('运行中…')).toBeTruthy()
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3
README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736
README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
README.zh.md: 37d8642e8d6d52a2d95e86207649b7a6ce3e8246

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。

View File

@@ -1,7 +1,99 @@
.dialog {
width: min(420px, 100%);
.page {
position: relative;
z-index: 1;
width: min(640px, calc(100vw - 64px));
max-height: 100vh;
padding: clamp(104px, 18vh, 156px) 0 40px;
box-sizing: border-box;
overflow-y: auto;
color: var(--dsw-alias-label-primary);
}
.brand {
display: flex;
align-items: center;
margin-bottom: 42px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.02em;
outline: none;
}
.description {
margin: 16px 0 0;
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 32px;
}
.primary {
width: 100%;
min-width: 132px;
}
.brand,
.title,
.description,
.actions {
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.title { animation-delay: 40ms; }
.description { animation-delay: 80ms; }
.actions { animation-delay: 120ms; }
@keyframes credential-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.brand,
.title,
.description,
.actions {
animation: none;
}
}
@media (max-width: 560px) {
.page {
width: calc(100vw - 40px);
padding-top: 64px;
}
.brand {
margin-bottom: 30px;
}
.actions {
align-items: stretch;
flex-direction: column-reverse;
margin-top: 32px;
}
.primary,
.later {
width: 100%;
}
}

View File

@@ -1,13 +1,13 @@
/**
* Official-DeepSeek first-run dialog. Readiness comes from the same
* Official-DeepSeek first-run step. Readiness comes from the same
* provider/settings/credential join as the Models page; the prompt only
* routes the user to that page's single credential editor.
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
@@ -37,29 +37,35 @@ function assertNever(_value: never): never {
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the controlled modal or null when onboarding needs no intervention.
* @returns the onboarding page or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { active, openSection, controller, useSnapshot, t } = props
const { complete, openSection, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const readiness = deepSeekReadiness(state)
const [dismissed, setDismissed] = useState(false)
const titleRef = useRef<HTMLHeadingElement | null>(null)
useEffect(() => {
if (active && !dismissed && state.status === 'idle') void controller.load()
}, [active, controller, dismissed, state.status])
if (state.status === 'idle') void controller.load()
}, [controller, state.status])
const close = (): void => {
setDismissed(true)
}
useEffect(() => {
if (
readiness.kind === 'adapter-absent'
|| readiness.kind === 'configured'
|| readiness.kind === 'unavailable'
) complete()
}, [complete, readiness.kind])
useEffect(() => {
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
}, [readiness.kind])
const openModels = (): void => {
close()
complete()
openSection('models')
}
if (!active || dismissed) return null
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
@@ -74,23 +80,25 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
}
return (
<Modal
open
onClose={close}
title={t('onboardingTitle')}
closeLabel={t('onboardingLater')}
description={t('onboardingDescription')}
className={styles['dialog'] as string}
footer={(
<Button
variant="primary"
className={styles['primary']}
autoFocus
onClick={openModels}
>
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2
ref={titleRef}
id="deepseek-onboarding-title"
className={styles['title']}
tabIndex={-1}
>
{t('onboardingTitle')}
</h2>
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['actions']}>
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
)}
/>
</div>
</section>
)
}

View File

@@ -24,8 +24,8 @@ function fail<T>(message: string): RpcResponse<T> {
function harness(options: {
provider?: boolean
providerActive?: boolean
providerSettingsNs?: string
providerActive?: boolean
settingsNamespace?: boolean
apiKeyEnv?: string | null
literal?: boolean
@@ -40,9 +40,7 @@ function harness(options: {
const face = {
llm: {
providers: () => {
if (options.providersReject === true) {
return Promise.reject(new Error('provider transport unavailable'))
}
if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable'))
return Promise.resolve(ok({
providers: options.provider === false
? []
@@ -91,9 +89,11 @@ function harness(options: {
}
const controller = new ModelsSettingsStore(face as never)
const openSection = vi.fn()
const complete = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
const props: DeepSeekOnboardingDialogProps = {
active: true,
stepId: 'deepseek-official',
complete,
openSection,
useSessions: unusedHook,
useWorkspaces: unusedHook,
@@ -101,36 +101,36 @@ function harness(options: {
useSnapshot: bindSnapshotSelector(controller.store),
t: key => en[key],
}
return { controller, openSection, props, configure: () => { fileConfigured = true } }
return { controller, complete, openSection, props, configure: () => { fileConfigured = true } }
}
describe('DeepSeekOnboardingDialog', () => {
it('loads on first entry and presents one accessible route to Models', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy()
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
expect(action).toBeTruthy()
expect(document.activeElement).toBe(action)
expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle }))
expect(screen.queryByRole('textbox')).toBeNull()
})
it('opens the Models section and dismisses the prompt', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.complete).toHaveBeenCalledOnce()
expect(h.openSection).toHaveBeenCalledWith('models')
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
})
it('allows configure-later dismissal without opening settings', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(h.complete).toHaveBeenCalledOnce()
expect(h.openSection).not.toHaveBeenCalled()
})
@@ -146,7 +146,8 @@ describe('DeepSeekOnboardingDialog', () => {
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('region')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
expect(h.openSection).not.toHaveBeenCalled()
view.unmount()
}
@@ -161,7 +162,8 @@ describe('DeepSeekOnboardingDialog', () => {
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('region')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
view.unmount()
}
})
@@ -169,18 +171,10 @@ describe('DeepSeekOnboardingDialog', () => {
it('closes when an external credential invalidation refreshes the shared join', async () => {
const h = harness()
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('dialog')
await screen.findByRole('region')
h.configure()
await act(async () => { await h.controller.load() })
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
})
it('stays hidden while the onboarding owner is inactive', async () => {
const h = harness()
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() })
expect(h.complete).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,117 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner row,
markdown code-block font) so a read card and a fenced code block read as one
family. Content keeps `white-space: pre` and scrolls horizontally rather than
folding, because a source line's indentation is part of what a reader is
reading. */
.block {
--dsl-read-radius: 12px;
--dsl-read-line-height: 22px;
/* Fixed-width gutter column for the line numbers, so the content edge stays
put down the whole window regardless of how wide the numbers grow. */
--dsl-read-gutter: 48px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-read-radius);
}
.banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-read-radius);
border-top-right-radius: var(--dsl-read-radius);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.count {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.lang {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.copyButton {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* One row per file line: a fixed gutter column, then the content. No wrapping —
a source line's leading whitespace is meaningful and scrolls sideways. */
.line {
display: flex;
min-height: var(--dsl-read-line-height);
line-height: var(--dsl-read-line-height);
white-space: pre;
}
.gutter {
flex: none;
width: var(--dsl-read-gutter);
padding-right: 14px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
/* The gutter is chrome, not content: keep it out of a text selection so a
copy of the visible rows carries the source, not the line numbers. */
user-select: none;
}
.content {
color: var(--dsw-alias-label-primary);
}
.expand {
display: block;
width: 100%;
padding: 0 0 0 var(--dsl-read-gutter);
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,172 @@
// ReadBlock: the file surface for a read tool result — a banner (label +
// language + a "showing N of M" note when the read is a window + a copy
// control) over line-numbered, syntax-highlighted source. Each row carries the
// file's OWN line number in a gutter, so a windowed read past an offset keeps
// its file numbering rather than re-counting from 1. Highlighting reuses the
// CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter
// needs; an unknown or absent language renders plain monospace. Long content is
// height-capped with the same head/tail arithmetic TerminalBlock uses, so the
// two cards collapse a long body at the same place. Colors resolve through
// --shiki-*/--dsw-* tokens.
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import {
grammarLoadCount,
highlightLines,
subscribeGrammarLoaded,
type HighlightSpan,
} from './markdown/highlight.ts'
import css from './ReadBlock.module.css'
/**
* Content lines shown before the height cap collapses the middle. Matches
* TerminalBlock's default so a long read and a long command output cut at the
* same place in the same flow.
*/
export const DEFAULT_READ_MAX_LINES = 16
/** One line of the read window: its file line number and its text (no trailing newline). */
export interface ReadBlockLine {
/** 1-based line number in the file (a window past an offset keeps the file's own numbering). */
number: number
/** The line's text, already truncated to the read tool's per-line cap. */
text: string
}
export interface ReadBlockProps {
/** Banner label (the file path, or a tool-supplied replacement title); omitted draws no label. */
label?: string | undefined
/** The returned window's lines, in file order, each keeping its file line number. */
lines: readonly ReadBlockLine[]
/** Exact total line count in the file, for the "showing N of M" note when the read is a window. */
totalLines: number
/** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */
lang?: string | undefined
/** Height cap in content lines before the middle collapses (default {@link DEFAULT_READ_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/**
* Render one line's highlighted runs. The css-variables theme colors every run,
* so each run is a styled span; a line with no highlighting at all takes the
* bare-text path in the caller instead (an unknown or absent language).
* @param spans - the line's styled runs.
* @returns the line's children.
*/
function renderSpans(spans: readonly HighlightSpan[]) {
return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
}
/**
* Render a read tool result as a line-numbered, optionally syntax-highlighted
* file view.
* @param props - see {@link ReadBlockProps}.
* @returns the read block element.
*/
export function ReadBlock({
label,
lines,
totalLines,
lang,
maxLines = DEFAULT_READ_MAX_LINES,
className,
}: ReadBlockProps) {
// The raw text the copy control writes and the highlighter tokenizes: the
// window's lines joined by newlines, without the file numbers or any chrome.
// Highlighting the whole window in one call (not line by line) keeps grammar
// context across lines — a multi-line string or comment stays one construct.
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
// Re-render when a lazy grammar finishes loading, so a read card that showed
// plain text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
// unknown/absent (or not-yet-loaded) language, when every line renders as
// bare text.
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The window's raw text, never the rendered tree: the gutter numbers and the
// banner are chrome the file does not contain.
void writeClipboard(raw).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, raw])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock's height cap, so a long read and a
// long command output slice their head and tail at the same place.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
// A read is a window when its returned lines are fewer than the file's total;
// the note states that so a reader is not misled that the file ends here.
const windowed = lines.length < totalLines
/**
* Render a slice of the line array as gutter-numbered rows.
* @param slice - the lines to draw, each with its aligned run array.
* @returns the row elements.
*/
const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) =>
slice.map(([line, spans]) => (
<div key={line.number} className={css.line}>
<span className={css.gutter} aria-hidden>{line.number}</span>
<span className={css.content}>{spans === undefined ? line.text : renderSpans(spans)}</span>
</div>
))
// Pair each line with its aligned run array up front, so head/tail slicing
// keeps the two in step without re-indexing.
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
[line, highlighted?.[index]])
return (
<div className={clsx(css.block, className)} data-read="">
<div className={css.banner}>
<div className={css.label}>{label ?? ''}</div>
<div className={css.action}>
{windowed && (
<span className={css.count}>{`显示 ${lines.length} / ${totalLines} 行`}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* Hide copy on an empty window, matching TerminalBlock's empty-output
guard: a successful read of an empty file returns lines: [] with
card:'read', so this branch is reachable, and copying then would
wipe the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
</div>
<div className={css.body}>
{rows(capped ? paired.slice(0, headLines) : paired)}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起内容' : `展开其余 ${hidden} 行`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden} 行`}
</button>
)}
{capped && rows(paired.slice(paired.length - tailLines))}
</div>
</div>
)
}

View File

@@ -24,6 +24,8 @@ export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'

View File

@@ -4,10 +4,10 @@
// plain fallback for everything else. Chrome (language banner + copy) matches
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { highlightToHtml } from './highlight.ts'
import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
@@ -25,7 +25,11 @@ export interface CodeBlockProps {
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)

View File

@@ -5,10 +5,17 @@
* theme package's token sheets as `--shiki-*` custom properties (light and
* dark blocks), never here — the repo's tokens-only styling rule.
*
* Grammars are the set the harness actually renders: TypeScript programs
* (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
* and JSON payloads. An unknown or absent language falls back to plain text
* (no highlighting, still monospace) — never an error.
* Only the three markdown-fence and `run_code` grammars (TypeScript, shell,
* JSON) load into the singleton at boot — the set every session renders. The
* read card's wider extension set (the file-extension language hints the read
* tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
* markup, …) is imported lazily and registered the first time such a language
* is requested, so a session that never opens a read card in one of those
* languages pays neither the ~1.6 MB of grammar modules nor their synchronous
* init. The first render of a lazy language falls back to plain text while its
* grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
* with highlighting. An unknown or absent language falls back to plain text (no
* highlighting, still monospace) — never an error.
*/
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
@@ -17,12 +24,69 @@ import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
import type { HighlighterCore } from 'shiki/core'
import type { CSSProperties } from 'react'
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
* from a boot grammar so no direct `@shikijs/types` dependency is needed. */
type LangModule = { default: typeof langTs }
/**
* Language ids (and aliases) the singleton registers; everything else renders
* Grammars the singleton loads at boot; each entry's own `name` is the id
* `codeToTokens`/`codeToHtml` resolve. The JS-family aliases (js/jsx/ts/tsx)
* resolve to the TypeScript grammar rather than a separate one: it tokenizes
* plain TS/JS exactly, and JSX/TSX approximately (shiki's TS grammar is not the
* dedicated TSX grammar, so JSX elements tokenize imperfectly) — an accepted
* trade to keep the boot set to one JS-family grammar. The read card's wider
* set loads lazily through {@link LAZY_GRAMMARS}.
*/
const LANGS = [langTs, langBash, langJson]
/**
* The read card's extension grammars, each behind a dynamic import so its
* module stays out of the boot chunk until a read of that language renders.
* Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
* `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
* hands the whole array to `loadLanguageSync`, which registers each entry
* (including embedded sub-grammars). The three boot grammars are absent —
* already loaded, so no alias value ever points at a missing entry here.
*/
const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
['python', () => import('@shikijs/langs/python')],
['ruby', () => import('@shikijs/langs/ruby')],
['go', () => import('@shikijs/langs/go')],
['rust', () => import('@shikijs/langs/rust')],
['java', () => import('@shikijs/langs/java')],
['c', () => import('@shikijs/langs/c')],
['cpp', () => import('@shikijs/langs/cpp')],
['csharp', () => import('@shikijs/langs/csharp')],
['kotlin', () => import('@shikijs/langs/kotlin')],
['swift', () => import('@shikijs/langs/swift')],
['php', () => import('@shikijs/langs/php')],
['yaml', () => import('@shikijs/langs/yaml')],
['toml', () => import('@shikijs/langs/toml')],
['ini', () => import('@shikijs/langs/ini')],
['markdown', () => import('@shikijs/langs/markdown')],
['mdx', () => import('@shikijs/langs/mdx')],
['html', () => import('@shikijs/langs/html')],
['css', () => import('@shikijs/langs/css')],
['scss', () => import('@shikijs/langs/scss')],
['less', () => import('@shikijs/langs/less')],
['sql', () => import('@shikijs/langs/sql')],
['xml', () => import('@shikijs/langs/xml')],
['lua', () => import('@shikijs/langs/lua')],
])
/**
* Language ids (and aliases) the highlighter accepts; everything else renders
* plain. A Map, not an object: fence info strings are assistant-authored, so
* a label like `constructor` or `__proto__` must miss instead of resolving an
* inherited property and crashing the renderer inside shiki.
* inherited property and crashing the renderer inside shiki. Keys cover both
* the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids
* the read tool's `langFromPath` emits, so both callers resolve the same
* grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for
* the JSX/TSX approximation), unchanged from when this was the only
* non-shell/JSON grammar. A value not in {@link LANGS} names a
* {@link LAZY_GRAMMARS} entry loaded on first use.
*/
const LANG_ALIASES = new Map<string, string>([
['typescript', 'typescript'],
@@ -30,6 +94,7 @@ const LANG_ALIASES = new Map<string, string>([
['tsx', 'typescript'],
['javascript', 'typescript'],
['js', 'typescript'],
['jsx', 'typescript'],
['shellscript', 'shellscript'],
['bash', 'shellscript'],
['sh', 'shellscript'],
@@ -37,6 +102,35 @@ const LANG_ALIASES = new Map<string, string>([
['zsh', 'shellscript'],
['json', 'json'],
['jsonc', 'json'],
['py', 'python'],
['python', 'python'],
['rb', 'ruby'],
['ruby', 'ruby'],
['go', 'go'],
['rs', 'rust'],
['rust', 'rust'],
['java', 'java'],
['c', 'c'],
['cpp', 'cpp'],
['cs', 'csharp'],
['csharp', 'csharp'],
['kotlin', 'kotlin'],
['swift', 'swift'],
['php', 'php'],
['yaml', 'yaml'],
['yml', 'yaml'],
['toml', 'toml'],
['ini', 'ini'],
['md', 'markdown'],
['markdown', 'markdown'],
['mdx', 'mdx'],
['html', 'html'],
['css', 'css'],
['scss', 'scss'],
['less', 'less'],
['sql', 'sql'],
['xml', 'xml'],
['lua', 'lua'],
])
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
@@ -52,12 +146,68 @@ let singleton: HighlighterCore | undefined
function highlighter(): HighlighterCore {
singleton ??= createHighlighterCoreSync({
themes: [cssVariablesTheme],
langs: [langTs, langBash, langJson],
langs: LANGS,
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
return singleton
}
/** Grammar ids whose lazy import is in flight or done, so it is requested once. */
const requested = new Set<string>()
/** Subscribers re-rendered after a lazy grammar registers (React callers). */
const listeners = new Set<() => void>()
/** Bumped on each lazy-grammar load; the `useSyncExternalStore` snapshot. */
let loadCount = 0
/**
* Subscribe to lazy-grammar load completions; `listener` fires after a
* {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a
* caller that rendered its plain fallback while the grammar loaded can
* re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with
* {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function.
* @param listener - invoked (no args) on each grammar-load completion.
* @returns a disposer that removes the listener.
*/
export function subscribeGrammarLoaded(listener: () => void): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
/**
* The lazy-grammar load counter — a value that changes on every load, so a
* `useSyncExternalStore` snapshot re-renders the subscriber when a grammar
* registers. Opaque: only its identity across renders matters.
* @returns the current load count.
*/
export function grammarLoadCount(): number {
return loadCount
}
/**
* Ensure the grammar `resolved` names is registered. A boot grammar (not in
* {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready
* synchronously; a lazy grammar not yet loaded starts its import (once) and
* reports not-ready, so the caller renders plain until a
* {@link subscribeGrammarLoaded} listener fires.
* @param resolved - the grammar id an alias resolved to.
* @returns whether the grammar is registered and ready to tokenize now.
*/
function ensureGrammar(resolved: string): boolean {
const load = LAZY_GRAMMARS.get(resolved)
// A boot grammar (already registered) has no lazy loader; it is always ready.
if (load === undefined) return true
if (highlighter().getLoadedLanguages().includes(resolved)) return true
if (!requested.has(resolved)) {
requested.add(resolved)
void load().then((mod) => {
highlighter().loadLanguageSync(mod.default)
loadCount += 1
for (const listener of listeners) listener()
})
}
return false
}
// Engine + grammar construction costs a long task (~120-175ms); building it
// during the first finalized fence's render would jank exactly when a stream
// completes. Warm the singleton in a deferred task at module load (= plugin
@@ -70,13 +220,59 @@ const warmupTimer = setTimeout(() => { highlighter() }, 0)
/**
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
* when `lang` maps to a registered grammar; `undefined` means the caller
* renders its plain fallback.
* renders its plain fallback. A lazy grammar not yet loaded returns `undefined`
* for this call and loads in the background; subscribe with
* {@link onGrammarLoaded} to re-highlight once it registers.
* @param code - the source text.
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
* @returns the highlighted HTML, or `undefined` for unknown languages.
* @returns the highlighted HTML, or `undefined` for unknown or not-yet-loaded languages.
*/
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
}
/**
* One highlighted run of a line: the text and the inline style shiki assigned
* it. The css-variables theme colors every run through a `--shiki-*` custom
* property, so `style.color` is always present; it is held as a style object
* rather than a bare color so a run spreads onto a `<span style>` uniformly.
*/
export interface HighlightSpan {
text: string
style: CSSProperties
}
/**
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
* registered grammar; `undefined` means the caller renders its plain fallback.
* A line-numbered view needs the token runs split per line (one gutter number
* per line), which the single-`<pre>` {@link highlightToHtml} does not expose,
* so this returns shiki's own 2D line/token structure narrowed to what a run
* renders. Each run's color is a `--shiki-*` custom property, keeping token
* colors on the theme package's sheets exactly as the HTML path does; the
* css-variables theme carries no font-style bits, matching that path's
* color-only output. The trailing newline shiki appends as a final empty line
* is dropped so the run count matches the caller's own line array.
* @param code - the source text.
* @param lang - the language hint (a file-extension-derived language id).
* @returns one entry per source line (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
*/
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
// third, empty line the caller's own line array does not carry. Drop that
// one terminator line so the two structures stay in step. The explicit
// `last !== undefined` (over `tokens[...]?.length`) keeps a single branch for
// per-file coverage, matching TerminalBlock's terminator check.
const last = tokens[tokens.length - 1]
const lines = tokens.length > 1 && last !== undefined && last.length === 0
? tokens.slice(0, -1)
: tokens
return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
}

View File

@@ -31,6 +31,24 @@ describe('highlightToHtml', () => {
expect(highlightToHtml('x', 'cobol')).toBeUndefined()
expect(highlightToHtml('x', undefined)).toBeUndefined()
})
// Every read-tool language hint whose grammar loads lazily (the boot set —
// ts/js/bash/sh/json — is covered above). Touching each one drives its own
// dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
const LAZY_ALIASES = [
'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
'xml', 'lua',
]
it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
// First touch returns the plain fallback (undefined) and starts the import.
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toBeUndefined()
// Once every grammar has registered, the same call highlights.
await vi.waitFor(() => {
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
})
})
})
describe('CodeBlock', () => {

View File

@@ -0,0 +1,241 @@
// @vitest-environment jsdom
// ReadBlock + the highlightLines token path: the banner (label, language, the
// "showing N of M" note only when the read is a window, copy control), the
// gutter-numbered rows keeping the file's own line numbers, the shiki per-line
// highlighting resolved to css-variables token spans with an identical-geometry
// plain fallback for an unknown/absent language, the head/tail height cap and
// its expand control, and the copy control writing the raw window text on both
// the accepted and refused clipboard paths.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** `count` lines starting at `first`, each with distinct text. */
function lines(count: number, first = 1): ReadBlockLine[] {
return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
}
/** The rendered rows as `<gutter><content>` strings (CSS-module class prefix). */
function rowTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
}
/** The gutter numbers of the rendered rows, in order. */
function gutters(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_gutter_"]')].map(cell => cell.textContent ?? '')
}
describe('highlightLines', () => {
it('tokenizes a registered grammar into per-line css-variables runs', () => {
const result = highlightLines('const x = 1\n// c', 'ts')
expect(result).not.toBeUndefined()
expect(result).toHaveLength(2)
// The keyword run carries a color style through a --shiki-* custom property.
const keyword = result![0]!.find(span => span.text === 'const')
expect(keyword?.style?.color).toContain('var(--shiki-')
// Whitespace between tokens is a run of its own; the comment is line two.
expect(result![0]!.map(span => span.text).join('')).toBe('const x = 1')
expect(result![1]!.map(span => span.text).join('')).toBe('// c')
})
it('colors every run through a --shiki-* custom property', () => {
// The css-variables theme colors even the whitespace run (as the foreground
// token), so every run is a styled span; the plain fallback is the whole
// unknown-language path, not a per-run one.
const result = highlightLines('const x = 1', 'ts')
for (const span of result!) for (const run of span) expect(run.style.color).toContain('var(--shiki-')
})
it('drops the trailing terminator line so the run count matches the source lines', () => {
// `a\n` tokenizes to two lines in shiki (the second empty); the caller's own
// line array has one entry, so the terminator line is dropped.
const result = highlightLines('const a = 1\n', 'ts')
expect(result).toHaveLength(1)
})
it('keeps a genuinely blank final line when the source ends in two newlines', () => {
const result = highlightLines('a\n\n', 'ts')
expect(result).toHaveLength(2)
expect(result![1]).toEqual([])
})
it('returns undefined for an unknown or absent language', () => {
expect(highlightLines('x', 'cobol')).toBeUndefined()
expect(highlightLines('x', undefined)).toBeUndefined()
})
it('loads a lazy grammar on first use: plain first, highlighted after it registers', async () => {
// A boot grammar (ts) is ready synchronously; a lazy grammar (python) is
// not, so the first call renders plain and imports the grammar, and a
// subscriber fires once it registers, after which the same call highlights.
let notified = 0
const stop = subscribeGrammarLoaded(() => { notified += 1 })
// First touch: grammar not loaded yet, so plain fallback while it imports.
expect(highlightLines('def f(): pass', 'py')).toBeUndefined()
// The import + loadLanguageSync resolve on a microtask; wait for the notify.
await vi.waitFor(() => { expect(notified).toBeGreaterThan(0) })
expect(grammarLoadCount()).toBeGreaterThan(0)
const result = highlightLines('def f(): pass', 'py')
expect(result).not.toBeUndefined()
// `def` is a python keyword and carries a --shiki-* color once highlighted.
const keyword = result!.flat().find(span => span.text === 'def')
expect(keyword?.style?.color).toContain('var(--shiki-')
stop()
})
})
describe('ReadBlock rows', () => {
it('renders one gutter-numbered row per line, keeping the file line numbers', () => {
const view = render(<ReadBlock label="a.ts" lines={lines(3, 41)} totalLines={3} />)
expect(gutters(view.container)).toEqual(['41', '42', '43'])
expect(rowTexts(view.container)).toEqual(['41line 41', '42line 42', '43line 43'])
})
it('highlights the content for a known language into token spans', () => {
const view = render(
<ReadBlock label="a.ts" lang="ts" lines={[{ number: 1, text: 'const a = 1' }]} totalLines={1} />,
)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
expect(content?.textContent).toBe('const a = 1')
})
it('renders the content as bare text with no span wrappers for an unknown language', () => {
const view = render(
<ReadBlock label="a.cob" lang="cobol" lines={[{ number: 1, text: 'IDENT DIVISION.' }]} totalLines={1} />,
)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span').length).toBe(0)
expect(content?.textContent).toBe('IDENT DIVISION.')
})
it('renders bare text when no language is given', () => {
const view = render(<ReadBlock label="x" lines={[{ number: 1, text: 'plain' }]} totalLines={1} />)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span').length).toBe(0)
expect(view.getByText('plain')).toBeTruthy()
})
})
describe('ReadBlock banner', () => {
it('shows the label, the language, and the count note when the read is a window', () => {
const view = render(<ReadBlock label="src/a.ts" lang="ts" lines={lines(3, 41)} totalLines={180} />)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(view.getByText('ts')).toBeTruthy()
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
})
it('omits the count note when the window is the whole file', () => {
const view = render(<ReadBlock label="a.ts" lines={lines(3)} totalLines={3} />)
expect(view.queryByText(/显示/u)).toBeNull()
})
it('draws an empty label and empty language when neither is given', () => {
const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
})
})
describe('ReadBlock height cap', () => {
it('renders every line and no expand control under the cap', () => {
const view = render(<ReadBlock label="a" lines={lines(4)} totalLines={4} maxLines={4} />)
expect(rowTexts(view.container)).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
// maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
const toggle = view.getByRole('button', { name: '展开其余 6 行' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 行')
fireEvent.click(toggle)
expect(rowTexts(view.container)).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起内容' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<ReadBlock label="a" lines={lines(5)} totalLines={5} maxLines={1} />)
expect(gutters(view.container)).toEqual(['1'])
expect(view.getByRole('button', { name: '展开其余 4 行' })).toBeTruthy()
})
it('caps at the documented default when maxLines is absent', () => {
const view = render(
<ReadBlock label="a" lines={lines(DEFAULT_READ_MAX_LINES + 1)} totalLines={DEFAULT_READ_MAX_LINES + 1} />,
)
expect(rowTexts(view.container)).toHaveLength(DEFAULT_READ_MAX_LINES)
expect(view.getByRole('button', { name: '展开其余 1 行' })).toBeTruthy()
})
})
describe('ReadBlock copy', () => {
it('copies the raw window text, joined by newlines, never the gutter numbers', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ReadBlock label="a" lines={lines(3, 41)} totalLines={180} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('line 41\nline 42\nline 43')
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
// While the ok label is showing, further clicks are no-ops.
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
expect(writeText).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('copies the whole window while the height cap hides its middle', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith(lines(10).map(line => line.text).join('\n'))
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
})
it('does not claim success when the host refuses the write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<ReadBlock label="a" lines={lines(1)} totalLines={1} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
it('merges className onto the wrapper', () => {
const view = render(<ReadBlock className="x" label="a" lines={lines(1)} totalLines={1} />)
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
})
it('hides the copy control for an empty window so it cannot wipe the clipboard', () => {
// A successful read of an empty file settles to lines: [] with card:'read',
// so this branch is reachable; copying then would clear the clipboard.
const view = render(<ReadBlock label="empty.ts" lines={[]} totalLines={0} />)
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6
README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a
README.md: 3e191b501e69062b671df0f237f2128a4ad086d1
README.zh.md: 44ba3eba8bfc756a7d68e43a3d34056349f7eaaa

View File

@@ -2,7 +2,9 @@
English | [中文](README.zh.md)
Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages.
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request.
## Model Experience

View File

@@ -2,7 +2,9 @@
[English](README.md) | 中文
设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。
## 模型体验

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-general",
"description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries",
"description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -26,7 +26,8 @@
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web"
},
@@ -35,23 +36,31 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -7,12 +7,18 @@
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
import { WelcomeNotice } from './WelcomeNotice.tsx'
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
import { en, zh, type SettingsKey } from './locales.ts'
export type {
@@ -21,6 +27,8 @@ export type {
export type {
GeneralSectionComponentProps,
} from './GeneralSection.tsx'
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
export type { WelcomeNoticeState } from './welcome-store.ts'
export type { SettingsKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -38,7 +46,7 @@ const NS = 'settings'
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration goes through declaration-aware deferral.
*/
export const inject = ['slots', 'locale']
export const inject = ['slots', 'locale', 'connection']
/**
* Register the `settings` dictionaries, the chrome content, and the General
@@ -52,6 +60,25 @@ export function apply(ctx: ClientContext): void {
// seat, and the nav label is a thunk the owner resolves per render — no
// locale/change re-registration wiring.
const t = ctx.locale.bind(NS)
const connection = ctx.get('connection') as ConnectionHandle
const welcomeController = new WelcomeNoticeStore(connection.api)
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
const welcomeInjected = (): WelcomeNoticeInjected => ({
controller: welcomeController,
useSnapshot: useWelcomeSnapshot,
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
refreshWelcomeIfLoaded(welcomeController)
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: welcome invalidations')
ctx.effect(() => {
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
@@ -68,11 +95,20 @@ export function apply(ctx: ClientContext): void {
locale: NS,
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
}, GeneralSection))
const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
ctx.slots.register({
name: 'settings.onboarding',
id: 'welcome-notice',
order: -100,
locale: NS,
inject: welcomeInjected,
}, WelcomeNotice))
return () => {
trigger.dispose()
header.dispose()
close.dispose()
general.dispose()
welcome.dispose()
}
}, 'ui-settings-general: chrome and section registrations')
}, 'ui-settings-general: chrome, section, and onboarding registrations')
}

View File

@@ -1,4 +1,5 @@
/** Shell chrome and General-nav dictionaries; feature rows own their copy. */
/** Shell chrome, General-nav, and welcome-notice dictionaries; feature rows own their copy. */
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
@@ -6,6 +7,14 @@ export const zh = {
'title': '设置',
'close': '关闭',
'general.nav': '通用设置',
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3],
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
'welcome.error': '暂时无法保存确认状态,请重试。',
} satisfies Record<string, string>
/** The settings namespace key union. */
@@ -17,4 +26,12 @@ export const en = {
'title': 'Settings',
'close': 'Close',
'general.nav': 'General',
'welcome.title': WELCOME_NOTICE_COPY.en.title,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3],
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
} satisfies Record<SettingsKey, string>

View File

@@ -1,4 +1,31 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the general settings plugin. */
export function apply(): void {}
import type { Context } from 'cordis'
import z from 'schemastery'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
} from './onboarding-copy.ts'
export {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from './onboarding-copy.ts'
interface OnboardingSettings {
welcomeNoticeVersion?: string
}
const OnboardingSettingsSchema: z<OnboardingSettings> = z.object({
[WELCOME_NOTICE_ACK_FIELD]: z.string(),
})
/** Register the durable GUI-onboarding section when a settings provider exists. */
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
OnboardingSettingsSchema,
)
})
}

View File

@@ -15,10 +15,9 @@ export const name = 'client-ui-settings-general-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a copy-owning registrant contributing chrome content
* and the General section into shell-declared slots — it emits no cordis
* events and owns no cross-plugin mutable relation; slot conflicts already
* fail loud in the slot core at load time.
* No runtime invariant: the settings seam validates and publishes the durable
* welcome section, while slot conflicts fail loud in the slot core; this
* package owns no additional event/data relationship between those systems.
*/
const install: InvariantInstaller = () => {}

View File

@@ -8,17 +8,21 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
/** The four seats this plugin fills (slot name → expected component). */
/** The five seats this plugin fills (slot name → expected component). */
const SEATS = [
['settings.trigger', TriggerContent],
['settings.header', HeaderContent],
['settings.close', CloseLabel],
['settings.section', GeneralSection],
['settings.onboarding', WelcomeNotice],
] as const
async function bench() {
@@ -26,7 +30,25 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
result: {
ok: true as const,
value: {
writable: true,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
value: {},
applies: 'live' as const,
secrets: [],
revision: 0,
}],
},
},
}))
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
}
/** Declare the shell's four child slots the way ui-settings' entry does. */
@@ -39,6 +61,7 @@ function declare(slots: SlotsService): () => void {
'settings.header': { kind: 'single', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
} as never,
() => null,
@@ -51,10 +74,10 @@ function generalEntry(slots: SlotsService) {
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'locale', 'connection'])
})
it('fills all four seats for declarations before or after apply', async () => {
it('fills all five seats for declarations before or after apply', async () => {
const before = await bench()
declare(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
@@ -67,6 +90,8 @@ describe('ui-settings-general apply', () => {
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
expect(before.slots.entries('settings.general.item')).toEqual([])
const welcome = before.slots.entries('settings.onboarding')[0]!
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
// Copy rides the standard locale seat: every seat declares the namespace.
for (const [name] of SEATS) {
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
@@ -118,6 +143,22 @@ describe('ui-settings-general apply', () => {
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
})
it('refreshes loaded welcome state only for its settings namespace or a reconnect', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')[0]!
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', 'unrelated')
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
})
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)

View File

@@ -9,10 +9,4 @@ describe('invariant companion', () => {
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general')
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})

View File

@@ -14,6 +14,9 @@
{
"path": "../ui-slots"
},
{
"path": "../connection"
},
{
"path": "../ui-primitives"
},
@@ -23,9 +26,15 @@
{
"path": "../ui-settings"
},
{
"path": "../web-react"
},
{
"path": "../locale"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
README.md: eaa588489bbb6369a9fe073f0a9a37efb3af7d9f
README.zh.md: 4330ea90e487270c8da31dace3d531d94f61b8de
README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c
README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
The shell supplies onboarding registrants only two navigation facts: whether the session surface is the empty Hero and an `openSection(id)` callback that opens the panel on a registered section. Registrants own capability readiness, dismissal, copy, and mutations; the shell therefore does not become a second configuration fact source.
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
## Model Experience

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
外壳只向首次使用注册方提供两个导航事实:当前会话界面是否为空白 Hero,以及一个 `openSection(id)` 回调;后者会打开设置面板并切换到已注册的指定分区。能力就绪状态、浮层关闭、文案和变更操作均由注册方持有,因此外壳不会成为第二个配置事实来源。
外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
## 模型体验

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings",
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and root-scoped onboarding overlays",
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -43,7 +43,8 @@
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -52,9 +53,11 @@
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react-dom": "~18.3.0",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -209,3 +209,33 @@
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* First-run stage: keep the product top bar visible, then let onboarding own
the complete workspace instead of presenting another settings modal. */
.onboardingOverlay {
position: fixed;
inset: 0;
z-index: 1100;
}
/* Mask */
.onboardingMask {
position: absolute;
left: 0px;
right: 0px;
top: 80px;
bottom: 0px;
background: rgba(0, 0, 0, 0.24);
/* Mask-blur */
backdrop-filter: blur(2px);
}
.onboardingStage {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
justify-content: center;
overflow: hidden;
background: var(--dsw-alias-bg-layer-1);
}

View File

@@ -6,10 +6,11 @@
* names resolve to that content (trigger: its own text; dialog:
* aria-labelledby the title node; close: visually-hidden slot text). Modal
* open state and the active section id are component-local viewing state;
* the onboarding slot receives the sessions-derived empty-Hero fact and a
* private callback that opens one registered section.
* the onboarding coordinator mounts exactly one ordered registrant while the
* sessions-derived empty-Hero fact is active.
*/
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
@@ -95,9 +96,10 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
* @returns the settings shell element tree.
*/
export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, useSections, useSessions, renderSlot } = props
const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
const [open, setOpen] = useState(false)
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
const close = useCallback(() => {
setOpen(false)
setActiveId(undefined)
@@ -111,9 +113,33 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
// freshly localized text on locale change, and the trigger/header/close
// seats re-render through their own outlets' subscriptions.
const rows = useSections(s => s)
const onboardingSteps = useOnboardingSteps(s => s)
const onboardingActive = useSessions(state =>
state.phase === 'ready'
&& (state.current === undefined || state.byId[state.current]?.blank === true))
const onboardingStep = onboardingActive
? onboardingSteps.find(step => !completedOnboarding.has(step.id))
: undefined
useEffect(() => {
if (onboardingActive) return
setCompletedOnboarding(new Set())
}, [onboardingActive])
const completeOnboardingStep = useCallback((id: string) => {
setCompletedOnboarding((previous) => {
if (previous.has(id)) return previous
return new Set([...previous, id])
})
}, [])
useEffect(() => {
if (onboardingStep === undefined) return
const appRoot = document.getElementById('root')
if (appRoot === null) return
appRoot.inert = true
return () => { appRoot.inert = false }
}, [onboardingStep])
return (
<>
@@ -135,7 +161,18 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
onClose={close}
/>
)}
{renderSlot('settings.onboarding', { active: onboardingActive, openSection })}
{onboardingStep !== undefined && createPortal((
<div className={css.onboardingOverlay} role="presentation">
<div className={css.onboardingMask} aria-hidden="true" />
<div className={css.onboardingStage}>
{renderSlot('settings.onboarding', {
stepId: onboardingStep.id,
complete: () => { completeOnboardingStep(onboardingStep.id) },
openSection,
}, { only: onboardingStep.id })}
</div>
</div>
), document.body)}
</>
)
}

View File

@@ -48,10 +48,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
/**
* Root-scoped onboarding overlays contributed by settings features. The
* shell supplies whether the current navigation state is the empty Hero
* and a private callback that opens one settings section; registrants own
* readiness, copy, and dialog behavior.
* Root-scoped onboarding steps contributed by settings features. The
* shell mounts one ordered step at a time; the active registrant either
* completes itself or keeps ownership until the user completes its sole
* path. Registrants own readiness, copy, and dialog behavior.
*/
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
}
@@ -79,10 +79,12 @@ export interface SettingsSectionOwnerProps {
children?: never
}
/** Owner share of a settings-backed onboarding overlay. */
/** Owner share of the currently active settings-backed onboarding step. */
export interface SettingsOnboardingOwnerProps {
/** Whether the current UI is in its empty Hero/onboarding state. */
active: boolean
/** Stable id of the step currently selected by the coordinator. */
stepId: string
/** Complete or skip this step and transfer ownership to the next entry. */
complete: () => void
/** Open the settings panel directly on one registered section. */
openSection: (id: string) => void
}
@@ -94,6 +96,12 @@ export interface SettingsSectionRow {
label: string
}
/** One ordered onboarding step projected from a slot registration. */
export interface SettingsOnboardingStep {
id: string
order: number
}
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): the ledger's nav-row projection as a hooks-compartment source —
@@ -103,6 +111,8 @@ export type SettingsRootInjected = {
hooks: {
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable<readonly SettingsSectionRow[]>
/** settings.onboarding ledger projected into coordinator order. */
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
}
}

View File

@@ -2,10 +2,10 @@
* Settings shell plugin, browser half. A pure composition face: occupies the
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
* panel, declares its chrome, section, and onboarding slots, and projects the
* section ledger into panel navigation. The shell ships no copy and reads no
* locale state — all text arrives from registrants (ui-settings-general owns
* the chrome and General content; features own their rows, sections, and
* onboarding overlays). Export discipline: packages/client/AGENTS.md.
* section ledger into panel navigation. The shell ships no copy; it reads the
* optional locale revision only to resolve registrant-owned nav-label thunks.
* ui-settings-general owns the chrome and General content; features own their
* rows, sections, and onboarding pages. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.locale Context merge for the optional ctx.get('locale')
@@ -13,12 +13,15 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// copy of its own and takes no hard locale dependency).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
import type {
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
} from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
export type {
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
SettingsOnboardingOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps,
SettingsSectionRow, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
/**
@@ -42,6 +45,8 @@ export function apply(ctx: ClientContext): void {
let rowsVersion = -1
let rowsRevision = -1
let rows: readonly SettingsSectionRow[] = []
let onboardingVersion = -1
let onboardingSteps: readonly SettingsOnboardingStep[] = []
const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0
const injected = (): SettingsRootInjected => ({
hooks: {
@@ -72,6 +77,23 @@ export function apply(ctx: ClientContext): void {
}
},
},
onboardingSteps: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.onboarding')
if (version !== onboardingVersion) {
onboardingVersion = version
onboardingSteps = ctx.slots.entries('settings.onboarding')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id */
id: e.options.id ?? '',
order: e.options.order ?? 0,
}))
.sort((a, b) => a.order - b.order)
}
return onboardingSteps
},
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
},
},
})
ctx.effect(() => {

View File

@@ -83,6 +83,29 @@ describe('ui-settings apply', () => {
off()
})
it('projects onboarding entries into stable coordinator order', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const { onboardingSteps } = injectedOf(b.slots).hooks
b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
const steps = onboardingSteps.getSnapshot()
expect(steps).toEqual([
{ id: 'welcome', order: -100 },
{ id: 'credential', order: 0 },
{ id: 'default-order', order: 0 },
])
expect(onboardingSteps.getSnapshot()).toBe(steps)
const listener = vi.fn()
const off = onboardingSteps.subscribe(listener)
b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalledOnce()
off()
})
it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)

View File

@@ -8,6 +8,7 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
afterEach(cleanup)
type Row = { id: string; order: number; label: string }
type Step = { id: string; order: number }
/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
const SEAT_CONTENT: Record<string, string> = {
@@ -23,7 +24,11 @@ function mount({
{ id: 'general', order: 0, label: 'General' },
{ id: 'models', order: 10, label: 'Models' },
],
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[] } = {}) {
steps = [
{ id: 'welcome', order: -100 },
{ id: 'credential', order: 0 },
],
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
// Mutable row source standing in for the bound useSections hook; bump()
// plays a ledger change through the same observable contract.
let current = rows
@@ -46,6 +51,7 @@ function mount({
useSessions,
useWorkspaces: unusedHook,
wide,
useOnboardingSteps: select => select(steps),
useSections: (select) => {
const [, force] = useState(0)
useEffect(() => {
@@ -164,20 +170,41 @@ describe('SettingsPanel navigation', () => {
expect(screen.queryByTestId('section-general')).toBeNull()
})
it('hands Hero readiness and a direct section opener to onboarding registrants', () => {
it('mounts onboarding steps in order and transfers ownership only on completion', () => {
const { renderSlot } = mount()
const onboardingCall = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
expect(onboardingCall?.[1]).toMatchObject({ active: true })
const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
expect(first?.[2]).toEqual({ only: 'welcome' })
act(() => {
(onboardingCall?.[1] as { openSection: (id: string) => void }).openSection('models')
(first?.[1] as { complete: () => void }).complete()
;(first?.[1] as { complete: () => void }).complete()
})
const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
const second = onboardingCalls.at(-1)
expect(second?.[1]).toMatchObject({ stepId: 'credential' })
expect(second?.[2]).toEqual({ only: 'credential' })
act(() => {
(second?.[1] as { openSection: (id: string) => void }).openSection('models')
})
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByTestId('section-models')).toBeTruthy()
cleanup()
const active = mount({ onboardingActive: false }).renderSlot.mock.calls
.find(call => call[0] === 'settings.onboarding')
expect(active?.[1]).toMatchObject({ active: false })
const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
.filter(call => call[0] === 'settings.onboarding')
expect(inactive).toHaveLength(0)
})
it('makes the underlying application inert while onboarding owns the viewport', () => {
const appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.append(appRoot)
const { view } = mount()
expect(appRoot.inert).toBe(true)
view.unmount()
expect(appRoot.inert).toBe(false)
appRoot.remove()
})
it('falls back to the first row when the active entry unregisters', () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md
README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d
README.zh.md: bfa139596b5ef61c23d29575bdea5534fa82b158
README.md: b35e5dc110e908047338054337b309a77b7e0f68
README.zh.md: 9c5f83d987b584d58ffacadce4e469bb6ba81aa2

View File

@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
Rules:
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
- Capture user feedback and explicit instructions faithfully, especially corrections.
- Do NOT mention this summarization request or that the context was compacted.
- Output only the checkpoint text: do not call any tool or take any other action.

View File

@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
Rules:
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
- Capture user feedback and explicit instructions faithfully, especially corrections.
- Do NOT mention this summarization request or that the context was compacted.
- Output only the checkpoint text: do not call any tool or take any other action.

View File

@@ -58,7 +58,7 @@ const COMPACTION_INSTRUCTION = [
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
'',
'Rules:',
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
'- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
'- Do NOT mention this summarization request or that the context was compacted.',
'- Output only the checkpoint text: do not call any tool or take any other action.',

View File

@@ -1224,7 +1224,8 @@ describe('default one-shot summarizer', () => {
expect(messages[0]).toEqual(prefix)
const last = messages.at(-1)?.content[0]
const lastText = last?.type === 'text' ? last.text : ''
expect(lastText).toContain('Condense the conversation ABOVE')
expect(lastText).toContain('Write concise English engineering prose.')
expect(lastText).toContain('numeric values, function signatures, and syntax fragments.')
expect(lastText).toContain('## Primary Request and Intent')
})

View File

@@ -342,6 +342,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
expect(adapter.conversationRequests).toHaveLength(2)
expect(adapter.summaryRequests).toHaveLength(1)
const instruction = adapter.summaryRequests[0]!.messages.at(-1)?.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('') ?? ''
expect(instruction).toContain('Write concise English engineering prose.')
expect(instruction).toContain('numeric values, function signatures, and syntax fragments.')
expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL')
const retry = JSON.stringify(adapter.conversationRequests[1]!.messages)
expect(retry).toContain('RECOVERY CHECKPOINT')

View File

@@ -2287,6 +2287,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ScopeKey',
declaration: 'export type ScopeKey = object;',
},
{
name: 'SearchFileMatches',
declaration: 'export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n}',
},
{
name: 'SearchLineMatch',
declaration: 'export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n}',
},
{
name: 'SearchMatchesResultView',
declaration: 'export interface SearchMatchesResultView {\n card: \'search\';\n shape: \'matches\';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n}',
},
{
name: 'SearchPathsResultView',
declaration: 'export interface SearchPathsResultView {\n card: \'search\';\n shape: \'paths\';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n}',
},
{
name: 'SearchResultView',
declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;',
},
{
name: 'SendOptions',
declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}',
@@ -2857,7 +2877,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolResultView',
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;',
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;',
},
{
name: 'ToolRunContext',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: dcce455f9551318f3871e3df84c29789078fef7c
README.zh.md: 63eaa2e0b66797c74a1d4845c29ca970b63f5f99
README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda
README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a

View File

@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.

View File

@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。

View File

@@ -83,6 +83,11 @@ export type {
GenericResultView,
TerminalResultView,
DiffResultView,
SearchResultView,
SearchMatchesResultView,
SearchPathsResultView,
SearchFileMatches,
SearchLineMatch,
ReadResultView,
WebResultView,
WebSearchResultView,

View File

@@ -137,7 +137,7 @@ export interface ReadFileLine {
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView
/**
* The default completed card: an optional replacement title and reformatted
@@ -189,6 +189,83 @@ export interface DiffResultView {
diffs: FileDiff[]
}
/** One matched line inside a {@link SearchFileMatches} group: its 1-based line number and text. */
export interface SearchLineMatch {
/** 1-based line number of the match within its file. */
lineNumber: number
/** The matched line text, as the tool surfaced it (the per-line preview budget already applied). */
line: string
}
/** One file's grouped content matches for a {@link SearchMatchesResultView}, in first-seen file order. */
export interface SearchFileMatches {
/** The file the matches belong to (the model-facing display path). */
path: string
/** The file's matched lines, in output order. */
matches: SearchLineMatch[]
}
/**
* A completed content search (`grep`) rendered as a search card whose matches are
* grouped by file, so a capable UI can list each file as an expandable group of
* its matched lines. `shape: 'matches'` discriminates this variant from the path
* variant ({@link SearchPathsResultView}) within {@link SearchResultView}. The
* discriminant is `shape`, not `kind`, so it never collides with the
* {@link ToolCallKind} `kind` an icon-picking bridge reads off a call view.
*/
export interface SearchMatchesResultView {
card: 'search'
shape: 'matches'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** Matched lines grouped by file, in first-seen file order. */
files: SearchFileMatches[]
/**
* Whether the tool capped the inline result: `files` carries only the retained
* matches, not every match the search found. A UI shows a capped indicator so it
* never presents a partial group as complete.
*/
truncated: boolean
/** Total matches the search found before capping (equals the retained count when not `truncated`). */
total: number
}
/**
* A completed path search (`glob`) rendered as a search card whose result is a flat
* path list. `shape: 'paths'` discriminates this variant from the grouped-matches
* variant ({@link SearchMatchesResultView}) within {@link SearchResultView}.
*/
export interface SearchPathsResultView {
card: 'search'
shape: 'paths'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The discovered paths, in the tool's result order (the retained page when `truncated`). */
paths: string[]
/**
* Whether the tool capped the inline result: `paths` carries only the retained
* page, not every path the search found. A UI shows a capped indicator so it
* never presents a partial list as complete.
*/
truncated: boolean
/** Total paths the search found before capping (equals `paths.length` when not `truncated`). */
total: number
}
/**
* A completed search rendered as a search card, the result-time view a discovery
* tool (`grep`, `glob`) returns from `presentResult`. One `card: 'search'` view
* with two `shape`-discriminated variants: grouped-by-file content matches
* ({@link SearchMatchesResultView}) and a flat path list
* ({@link SearchPathsResultView}). Both carry a `truncated`/`total` signal so a UI
* never presents a capped result as complete. The view carries no result text: a
* UI without a search card falls back to the raw `tool/result` content. There is
* no call-time analogue: a search call stays a {@link GenericCallView}
* (`kind: 'search'`) because the pending state has no matches or paths to show —
* the structured shape exists only after `execute`.
*/
export type SearchResultView = SearchMatchesResultView | SearchPathsResultView
/**
* A completed file read rendered as a line-numbered, optionally syntax-highlighted
* code view by a capable UI. Set by a tool whose call reads file text (e.g.

View File

@@ -11,11 +11,12 @@
import type { Context } from 'cordis'
import { sep } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { globSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
@@ -43,6 +44,8 @@ export interface GlobToolCaps {
sampleOverCapGlobResults: boolean
/** Max paths retained inline; later paths go to the formatted spill file. */
maxResults: number
/** Max bytes of serialized `presentationMeta`; trailing paths drop past it. */
maxMetaBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
@@ -231,6 +234,24 @@ function renderGlobPaths(paths: string[], caps: GlobToolCaps, root: string, spil
return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef)
}
/**
* The inline page of paths a completed `glob` card shows, computed the SAME way
* {@link renderGlobPaths} computes its model-facing page so the card and the text
* agree on which paths survived the cap. A result within the cap is shown whole;
* an over-cap result is either the modification-time head or the top-level sample,
* matching the deployment's `sampleOverCapGlobResults`.
*
* @param paths - the complete discovered path list, in modification-time order.
* @param caps - the resolved glob caps (the inline cap and the sampling switch).
* @param root - the search root in the same display-path space as `paths`.
* @returns the inline page and whether the complete result was capped.
*/
function globCardPage(paths: string[], caps: GlobToolCaps, root: string): { items: string[]; truncated: boolean } {
if (paths.length <= caps.maxResults) return { items: paths, truncated: false }
if (!caps.sampleOverCapGlobResults) return { items: paths.slice(0, caps.maxResults), truncated: true }
return { items: sampleAcrossTopLevel(paths, caps.maxResults, root).items, truncated: true }
}
/**
* Pending-call presentation: a search card titled by the pattern (and root).
*
@@ -242,6 +263,24 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern }
}
/**
* Completed-call presentation: the search card projected from the result's
* `presentationMeta` (the discovered path list, with the truncation signal). A UI
* without a search card falls back to the raw `tool/result` content, so the view
* carries no result text of its own. Malformed or absent metadata (an obsolete or
* hand-edited replayed log) falls back to the generic card.
*
* @param _args - the raw tool arguments; unused, the view derives from the result.
* @param result - the final model-facing tool result carrying the projected metadata.
* @returns the search card view, or `undefined` for the generic fallback.
*/
export function presentGlobResult(_args: { pattern: string; path?: string }, result: ToolResult): SearchResultView | undefined {
if (result.isError) return undefined
const view = searchViewFromMeta(result.meta)
if (view === undefined || view.shape !== 'paths') return undefined
return view
}
/**
* Register the `glob` tool and its system-prompt guidance.
*
@@ -289,6 +328,10 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }],
presentationMeta: (_args, value) => {
const page = globCardPage(value.paths, caps, value.root)
return globSearchMeta({ items: page.items, truncated: page.truncated, seen: value.paths.length }, caps.maxMetaBytes)
},
},
async execute(args, exec) {
const input = parseGlobArgs(args)
@@ -305,6 +348,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
return { root, paths: all }
},
presentCall: presentGlobCall,
presentResult: presentGlobResult,
})
ctx.tools.register(tool)

View File

@@ -12,13 +12,14 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import type { GrepMatch } from './search-core.ts'
import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
@@ -41,6 +42,8 @@ export interface GrepToolCaps {
maxMatches: number
/** Max bytes retained per matched-line preview. */
maxLineBytes: number
/** Max bytes of serialized `presentationMeta`; trailing file groups drop past it. */
maxMetaBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
@@ -54,13 +57,6 @@ export interface GrepInput {
include?: string
}
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
export interface GrepMatch {
path: string
lineNumber: number
line: string
}
/**
* Reject an `include` that is not ONE positive glob filter: blank strings,
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
@@ -177,22 +173,6 @@ export function parseGrepMatches(stdout: string): GrepMatch[] {
return matches
}
/**
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
* mark the cut. The cap is a per-line budget fact; the complete line stays in
* the searched file for `read`.
*
* @param line - the matched line text (trailing newline already stripped).
* @param maxBytes - the preview budget in bytes.
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
*/
export function previewLine(line: string, maxBytes: number): string {
const retainer = new TextRetainer({ kind: 'head', maxBytes })
retainer.push(line)
const kept = retainer.finish()
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
}
/** `match` / `matches` for a count. */
function matchNoun(count: number): string {
return count === 1 ? 'match' : 'matches'
@@ -241,18 +221,10 @@ export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: S
return `${header}\n\n${body}\n\n(${recovery})`
}
/** Apply the Native per-line preview budget without changing the canonical matches. */
function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] {
return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) }))
}
/** Retain and format one canonical match list for the Native surface. */
function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string {
if (matches.length === 0) return 'No matches found'
const previewed = previewGrepMatches(matches, maxLineBytes)
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of previewed) retainer.push(match)
return formatGrepOutput(retainer.finish(), spillRef)
/** Format one already-retained match list for the Native surface. */
function formatRetainedGrep(retained: RetainedItems<GrepMatch>, spillRef?: SpillRef): string {
if (retained.seen === 0) return 'No matches found'
return formatGrepOutput(retained, spillRef)
}
/**
@@ -268,6 +240,27 @@ export function presentGrepCall(args: { pattern: string; path?: string; include?
return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern }
}
/**
* Completed-call presentation: the search card projected from the result's
* `presentationMeta` (matches grouped by file, with the truncation signal). A UI
* without a search card falls back to the raw `tool/result` content, so the view
* carries no result text of its own. Malformed or absent metadata (an obsolete or
* hand-edited replayed log) falls back to the generic card.
*
* @param _args - the raw tool arguments; unused, the view derives from the result.
* @param result - the final model-facing tool result carrying the projected metadata.
* @returns the search card view, or `undefined` for the generic fallback.
*/
export function presentGrepResult(
_args: { pattern: string; path?: string; include?: string },
result: ToolResult,
): SearchResultView | undefined {
if (result.isError) return undefined
const view = searchViewFromMeta(result.meta)
if (view === undefined || view.shape !== 'matches') return undefined
return view
}
/**
* Register the `grep` tool and its system-prompt guidance.
*
@@ -315,8 +308,10 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
},
render: (_args, value) => [{
type: 'text',
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes)),
}],
presentationMeta: (_args, value) =>
grepSearchMeta(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes),
},
async execute(args, exec) {
const input = parseGrepArgs(args)
@@ -335,6 +330,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
return { matches: all }
},
presentCall: presentGrepCall,
presentResult: presentGrepResult,
})
ctx.tools.register(tool)
@@ -344,17 +340,20 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
if (value === undefined) return decision
const matches = value.matches
if (matches.length <= caps.maxMatches) return decision
// The spill artifact holds the COMPLETE result: preview each line, but keep
// every match (no inline cap), so the recovery file is the full search.
const previewedAll = matches.map(match => ({ ...match, line: previewLine(match.line, caps.maxLineBytes) }))
const spillRef = await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`,
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`,
)
return {
kind: 'accept',
content: [{
type: 'text',
text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef),
text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef),
}],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}

View File

@@ -31,9 +31,9 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, sampleAcrossTopLevel } from './glob.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts'
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
export {
GREP_MAX_LINE_BYTES,
@@ -45,11 +45,20 @@ export {
parseGrepArgs,
parseGrepMatches,
presentGrepCall,
previewLine,
presentGrepResult,
} from './grep.ts'
export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts'
export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
export type { RipgrepRun, SearchErrorCode } from './search-core.ts'
export type { GrepInput, GrepToolCaps } from './grep.ts'
export {
RAW_OUTPUT_MAX_BYTES,
SEARCH_META_MAX_BYTES,
SEARCH_TIMEOUT_MS,
SearchError,
previewLine,
runRipgrep,
toWorkdirRelative,
trySaveFormattedResult,
} from './search-core.ts'
export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts'
export { singleQuote } from './shell-quote.ts'
/** Cordis plugin name used by loader diagnostics. */
@@ -68,6 +77,8 @@ export interface Config {
grepMaxMatches?: number
/** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
grepMaxLineBytes?: number
/** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted card stays bounded. */
searchMetaMaxBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
@@ -79,6 +90,7 @@ export const Config: z<Config> = z.object({
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES),
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
})
@@ -133,6 +145,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches)
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
if (!await ripgrepAvailable(ctx)) {
@@ -142,12 +155,14 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
applyGlobTool(ctx, {
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
maxResults: resolved.globMaxResults,
maxMetaBytes: resolved.searchMetaMaxBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
timeoutMs: resolved.timeoutMs,
})
applyGrepTool(ctx, {
maxMatches: resolved.grepMaxMatches,
maxLineBytes: resolved.grepMaxLineBytes,
maxMetaBytes: resolved.searchMetaMaxBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
timeoutMs: resolved.timeoutMs,
})

View File

@@ -19,6 +19,8 @@
import { isAbsolute, relative, sep } from 'node:path'
import type { Context } from 'cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -36,6 +38,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000
*/
export const SEARCH_TIMEOUT_MS = 30_000
/**
* Default cap in bytes on one search's serialized `presentationMeta` (the
* `searchMetaMaxBytes` config). The inline match/path caps already bound the item
* COUNT, but retained matches of a broad search (many long lines) can still
* serialize to hundreds of kilobytes, and `meta` is persisted with the session
* log and re-sent on every request. A deployment's final output budget
* (`dsh-spill-policy`) only shrinks a result's `content`, never its `meta`, so the
* projection owns this cap. 64 KiB holds the full default-capped result of a
* typical search while bounding the pathological one.
*/
export const SEARCH_META_MAX_BYTES = 65_536
/**
* Stable, machine-routable codes for search failures. Package-owned (not
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
@@ -212,6 +226,63 @@ export function toWorkdirRelative(path: string, workdir: string): string {
return rel
}
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
export interface GrepMatch {
path: string
lineNumber: number
line: string
}
/**
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
* mark the cut. The cap is a per-line budget fact; the complete line stays in
* the searched file for `read`.
*
* @param line - the matched line text (trailing newline already stripped).
* @param maxBytes - the preview budget in bytes.
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
*/
export function previewLine(line: string, maxBytes: number): string {
const retainer = new TextRetainer({ kind: 'head', maxBytes })
retainer.push(line)
const kept = retainer.finish()
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
}
/**
* Apply the shared inline cap to a canonical `grep` match list: preview each
* retained line to `maxLineBytes` and keep the first `maxMatches`. The single
* retention pass both the model-facing render ({@link module:@deepseek-ai/dsh-tool-fs-search/grep}
* `formatGrepOutput`) and the search-card projection
* ({@link module:@deepseek-ai/dsh-tool-fs-search/presentation} `grepSearchMeta`)
* consume, so text and card never disagree about which matches survived.
*
* @param matches - every match the search parsed (the canonical value's matches).
* @param maxMatches - the inline match cap (the `grepMaxMatches` config).
* @param maxLineBytes - the per-matched-line preview budget in bytes.
* @returns the retention outcome over the previewed matches.
*/
export function retainGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): RetainedItems<GrepMatch> {
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) })
return retainer.finish()
}
/**
* Apply the shared inline cap to a canonical `glob` path list: keep the first
* `maxResults`. The single retention pass both the model-facing render and the
* search-card projection consume.
*
* @param paths - every path the search discovered (the canonical value's paths).
* @param maxResults - the inline path cap (the `globMaxResults` config).
* @returns the retention outcome over the paths.
*/
export function retainGlobPaths(paths: string[], maxResults: number): RetainedItems<string> {
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
return retainer.finish()
}
/**
* Best-effort save of one COMPLETE formatted search result through
* `ctx.spillStore.saveText()` — the model-facing recovery path for a capped

View File

@@ -27,7 +27,9 @@ import {
formatGrepMatches,
parseGrepMatches,
presentGlobCall,
presentGlobResult,
presentGrepCall,
presentGrepResult,
previewLine,
sampleAcrossTopLevel,
toWorkdirRelative,
@@ -987,6 +989,73 @@ describe('presentation', () => {
expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' })
expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)')
})
it('grep projects a search card from a real execute, grouped by file with total and truncation', async () => {
const { ctx, bash } = await setup({ config: { grepMaxMatches: 2 } })
bash.handler = () => runResult([
matchLine('a.ts', 1, 'one'),
matchLine('a.ts', 2, 'two'),
matchLine('b.ts', 3, 'three'),
'',
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected grep success')
// The presentationMeta projection rides the result meta (a surface call).
expect(result.meta).toEqual({
shape: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: true,
total: 3,
})
const view = presentGrepResult({ pattern: 'e' }, result)
expect(view).toEqual({
card: 'search',
shape: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: true,
total: 3,
})
})
it('glob projects a search card from a real execute, a flat path list with total and truncation', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 2 } })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected glob success')
expect(result.meta).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
const view = presentGlobResult({ pattern: '*.ts' }, result)
expect(view).toEqual({ card: 'search', shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
})
it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n`)
const result = await call(ctx, 'grep', { pattern: 'o' }, {
agent: agent('/w'),
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected grep success')
expect(result.meta).toBeUndefined()
expect(presentGrepResult({ pattern: 'o' }, result)).toBeUndefined()
})
it('presentResult returns undefined for a failed result and for the other tool’s meta shape', () => {
const errorResult = { content: [{ type: 'text' as const, text: 'boom' }], isError: true }
expect(presentGrepResult({ pattern: 'x' }, errorResult)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, errorResult)).toBeUndefined()
// A grep result carrying a paths-shaped meta (and vice versa) is not this
// tool's shape: each presenter narrows to its own shape and otherwise falls back.
const pathsResult = { content: [], isError: false, meta: { shape: 'paths', paths: ['a.ts'], truncated: false, total: 1 } }
const matchesResult = { content: [], isError: false, meta: { shape: 'matches', files: [], truncated: false, total: 0 } }
expect(presentGrepResult({ pattern: 'x' }, pathsResult)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, matchesResult)).toBeUndefined()
})
it('presentResult falls back to the generic card on malformed replayed meta', () => {
const malformed = { content: [], isError: false, meta: { shape: 'matches', files: 'nope', truncated: false, total: 0 } }
expect(presentGrepResult({ pattern: 'x' }, malformed)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, { content: [], isError: false, meta: 42 })).toBeUndefined()
})
})
describe('helpers', () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 8f08d90f8a91afc2ff022761d2d83de055df18a0
README.zh.md: febeba0e601d75cbc69490d29135c206189efe29
README.md: 94698d02f599e10b1bbe69e2f9b1b058eb3e1647
README.zh.md: da63ff09d7ca52bdc10c8a8bce81be4c2a0abf0d

View File

@@ -34,7 +34,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit Web-preference allowlist, currently only `permission`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
## Carrier layer (`/client` + root)

View File

@@ -34,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 Web 偏好 allowlist,目前仅包含 `permission`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
## 载体层(`/client` + 根路径)

View File

@@ -84,6 +84,9 @@ const COLD_SUMMARY_BATCH_SIZE = 16
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
/** Product settings intentionally exposed beside model-provider namespaces. */
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding'])
/** Read live abort state across awaits without treating it as synchronously immutable. */
function isAborted(signal: AbortSignal): boolean {
return signal.aborted
@@ -1108,13 +1111,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/**
* The settings namespaces this proxy serves: configurable model providers
* plus the small explicit Web preference allowlist. The settings seam
* remains general; a future registration does not become remotely readable
* or writable by default.
* plus the small explicit Web preference and product-owned allowlists. The
* settings seam remains general; a future registration does not become
* remotely readable or writable by default.
*/
function exposedNamespaces(): Set<string> {
const exposed = modelProviderNamespaces()
for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns)
for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns)
return exposed
}

View File

@@ -160,8 +160,8 @@ async function harness(options?: {
await ctx.plugin(LlmService)
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
// Model-provider namespaces and the explicit Web preference allowlist are
// the proxy's complete settings surface.
// Model-provider namespaces plus the explicit Web preference and product
// onboarding allowlists are the proxy's complete settings surface.
if (options?.configurableProviders !== false) {
ctx.llm.registerConfigurableProviders([
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
@@ -226,8 +226,8 @@ describe('settings domain', () => {
// The settings seam is general: any plugin may register a namespace for
// its own configuration. The Web configuration plane remains opt-in, so a
// future internal plugin cannot become remotely configurable just by
// registering; permission is the one non-model namespace intentionally
// admitted by this surface.
// registering; permission and the product onboarding namespace are the
// non-model namespaces intentionally admitted by this surface.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
@@ -258,6 +258,21 @@ describe('settings domain', () => {
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
})
it('serves the product onboarding namespace without invalidating the model catalog', async () => {
const ctx = await harness()
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['ui-onboarding'])
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
expectOk(await api.settings.mutate(request({
ns: 'ui-onboarding',
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
})))
})
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
})
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
const ctx = await harness({ configurableProviders: false })
ctx.settings.register(NS, AdapterConfig)

View File

@@ -8,7 +8,7 @@
*/
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { TuiOverlaySession } from '../extension/types.ts'
import { displayText } from '../components/text.ts'
import {
@@ -37,6 +37,8 @@ export interface ModelController {
resetContextResolution(): void
/** Forget the tracked selector overlay (shutdown). */
clearOverlay(): void
/** Remove the adapter-registration listener (channel detach). */
detach(): void
}
type ContextResolution =
@@ -55,8 +57,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
let modelOverlay: TuiOverlaySession | undefined
let modelCommands = Promise.resolve()
// A route whose adapter has not registered yet. Loader activation order is
// service-driven, so the TUI can mount before a configured adapter plugin
// activates; that transient NO_ADAPTER is not an error — the resolution
// waits for the next `llm/adapters-updated` commit instead of surfacing it.
let awaitingAdapter = false
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
contextWindow = undefined
awaitingAdapter = false
const resolution: Promise<ContextResolution> = selected === undefined
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
@@ -67,6 +76,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
void resolution.then((result) => {
if (contextResolution !== resolution) return
if (result.kind === 'error') {
if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') {
awaitingAdapter = true
return
}
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
return
}
@@ -74,6 +87,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
deps.requestRender()
})
}
// The wait cannot go stale against `target.current`: every target change
// re-enters resolveContextWindow, which clears it. A commit that still
// lacks the route parks the resolution again rather than erroring, so
// unrelated topology changes stay silent. The disposer rides the channel's
// detachListeners() through detach(), matching the sibling listeners.
const disposeAdapterListener = ctx.on('llm/adapters-updated', () => {
if (deps.isDisposed() || !awaitingAdapter) return
resolveContextWindow(target.current)
})
resolveContextWindow(target.current)
const selectModel = (
@@ -187,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
clearOverlay(): void {
modelOverlay = undefined
},
detach(): void {
disposeAdapterListener()
},
}
}

View File

@@ -402,23 +402,26 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
// A generic card's own content, or a read card's `content` fallback (the
// A generic card's own content, a read card's `content` fallback (the
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
// read renders exactly as before the read card existed), or a web card's
// fallback to the raw result content (the `web` view carries no `content`
// copy), all render as one dim Markdown block below, so links/lists/headings
// keep the unified dim styling rather than reading as bare text. Terminal and
// diff cards own their body styling, so they are excluded (mirrors
// renderBody's post-terminal/diff fallback).
// read renders exactly as before the read card existed), or a search/web
// card's fallback to the raw result content (neither the `search` nor the
// `web` view carries a `content` copy), all render as one dim Markdown block
// below, so links/lists/headings keep the unified dim styling rather than
// reading as bare text. A search card thus stays byte-identical to the
// pre-search-card generic fallback. Terminal and diff cards own their body
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic' || view.card === 'read'
? view.content ?? this.result?.content
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
// handler sets both) and the pending callView is never a web card, so
// the optional-chain undefined side is unreachable here.
/* v8 ignore next */
: view.card === 'search'
? this.result?.content
: undefined
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
// handler sets both) and the pending callView is never a web card, so
// the optional-chain undefined side is unreachable here.
/* v8 ignore next */
? this.result?.content
: undefined
const unknownXml = this.definition === undefined && markdownContent !== undefined
? renderUnknownXml(
displayText(contentText(markdownContent)),
@@ -535,11 +538,12 @@ export class ToolCardComponent implements Component {
// rather than under the dim result-output color.
return { prelude: [...hunks, footer], lines: [] }
}
// A generic or read card carries its own envelope-stripped `content`; a `web`
// card carries no `content` copy and falls back to the raw result content
// here. (Mirrors the `markdownContent` selection in render(); a read card has
// no dedicated TUI rendering, so its `content` takes the same body path,
// keeping read output as it was before the read card existed.)
// A generic or read card carries its own envelope-stripped `content`; a
// search or web card carries no `content` copy and falls back to the raw
// result content here. (Mirrors the `markdownContent` selection in render();
// a read card has no dedicated TUI rendering, so its `content` takes the same
// body path, keeping read output as it was before the read card existed, and
// a search card stays byte-identical to the pre-search-card fallback.)
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
const prelude: string[] = []
const lines: string[] = []

View File

@@ -1563,6 +1563,7 @@ export function createTuiChat(
disposeAgent()
disposeSchemeListener()
disposeTargetListeners()
modelController.detach()
}
// Sweep reveal of the whole banner: the header wipes in left-to-right over

View File

@@ -10,6 +10,7 @@ import AgentRegistry, {
} from '@deepseek-ai/dsh-agent'
import { createUserMessage,
createToolResultMessage,
LlmError,
ReasoningEffortId,
type LlmCallConfig,
type LlmModelReasoningInfo,
@@ -3630,6 +3631,96 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(reasoningFailed)
})
it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => {
// Loader activation order is service-driven: the TUI can mount before a
// configured adapter plugin activates, so the initial resolveModelInfo
// fails with NO_ADAPTER. That transient state must not print an error;
// the resolution retries on llm/adapters-updated.
const adapters = new Set<string>()
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
contextTokens: 50_000,
catalog: {
providers: [],
models: [],
resolveModelInfo: () => adapters.has('openai-codex')
? Promise.resolve({ context: { contextWindow: 100_000 } })
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
},
})
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
// A topology commit that still lacks the route parks the wait again.
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('% context')
expect(result.terminal.output).not.toContain('Could not resolve model context')
adapters.add('openai-codex')
result.ctx.emit('llm/adapters-updated')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('% context')
})
expect(result.terminal.output).not.toContain('Could not resolve model context')
// A commit after satisfaction is a no-op for the resolved value.
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
await dispose(result)
})
it('stops listening for adapter registrations after channel detach', async () => {
// The listener disposer rides detachListeners() through the controller's
// detach(): after dispose, a registry commit must not re-enter resolution
// at all (the isDisposed() guard is a fallback, not the removal).
const calls: string[] = []
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
catalog: {
providers: [],
models: [],
resolveModelInfo: (provider) => {
calls.push(provider)
return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER'))
},
},
})
await tick()
const callsAtDetach = calls.length
await result.controller.dispose()
result.ctx.emit('llm/adapters-updated')
await tick()
expect(calls.length).toBe(callsAtDetach)
await result.ctx.fiber.dispose()
})
it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => {
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
resolveModelInfo: provider => provider === 'alpha'
? Promise.resolve({ context: { contextWindow: 64_000 } })
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
},
})
await tick()
// Switching the model re-resolves and clears the deferred wait, so the
// stale route's adapter arriving afterwards must be a no-op.
result.terminal.send('/model alpha/a1')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Model selected: alpha/a1')
})
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
await dispose(result)
})
it('does not render a model catalog that resolves after TUI disposal', async () => {
const deferred = Promise.withResolvers<never[]>()
const result = await setup({
@@ -4387,6 +4478,20 @@ describe('tool cards and surface replay', () => {
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
},
// A search card carries no result text of its own; the TUI has no dedicated
// search arm and falls back to the raw result content, rendered as the same
// dim generic body a pre-search-card grep/glob result showed.
search: {
name: 'search', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Grep todo', kind: 'search' }),
presentResult: () => ({
card: 'search',
shape: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'todo one' }] }],
truncated: false,
total: 1,
}),
},
symbolic: {
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
@@ -4424,6 +4529,7 @@ describe('tool cards and surface replay', () => {
['c12', 'symbolic', '{}'],
['c13', 'knownXml', '{}'],
['c16', 'webCard', '{}'],
['c17', 'search', '{"pattern":"todo"}'],
] as const
appendAssistant(result.session, [
{ type: 'text', text: 'Calling tools' },
@@ -4525,6 +4631,14 @@ describe('tool cards and surface replay', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c17' as never,
content: [{ type: 'text', text: 'Found 1 match\n\na.ts\nLine 1: todo one' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1,
step: 1,
@@ -4556,6 +4670,11 @@ describe('tool cards and surface replay', () => {
expect(output).toContain('$ blank desc command')
// A card whose title only repeats the name renders header-only (empty body).
expect(output).toContain('Tool / emptyBody')
// A search result view carries no `content` of its own, so the card renders
// the raw model-facing result text through the same dim generic body — the
// TUI has no dedicated search arm.
expect(output).toContain('Tool / search')
expect(output).toContain('Line 1: todo one')
// A diff card drops its title (the paths + change footer carry the meaning).
// The first file's path is head-visible; the second file and the change
// footer sit past this card's 4-line budget and appear only when expanded.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/web/tool-web/README.md
README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888
README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07
README.md: 12f5c806db66b2109888c1ec642d117f3432d0df
README.zh.md: cfbf47219f160af706912ca53f85cff535c381ec

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). Search guidance mentions `web_fetch` only when fetch is also config-enabled; a search-only composition instead tells the model to use returned snippets and cite their URLs.
## Tools
@@ -47,14 +47,20 @@ The tool never calls a provider's `available()` and never enumerates providers
#### What the model sees
Search and fetch contribute the web-search and web-fetch guidance below. A scoped tool restriction does not remove these independently registered sections.
Search and fetch contribute the web-search and web-fetch guidance below. Search chooses its fetch-enabled or search-only text from config at registration time. A scoped tool restriction does not remove these independently registered sections.
##### Web search guidance
##### Web search guidance with fetch enabled
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
```
##### Web search-only guidance
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
```
##### Web fetch guidance
```markdown
@@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
#### Token effect
Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema.
Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch changes the search guidance as well as registering or removing the fetch section.
#### KV Cache effect
Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
### Tool schemas

View File

@@ -4,7 +4,7 @@
面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。
## 工具
@@ -47,14 +47,20 @@
#### 模型看到的内容
搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。scope 工具限制不会移除这些独立注册的区段。
搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。搜索会在注册时根据配置选用启用抓取或仅搜索的文本。scope 工具限制不会移除这些独立注册的区段。
##### Web 搜索指引
##### 启用抓取时的 Web 搜索指引
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
```
##### 仅搜索时的 Web 搜索指引
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
```
##### Web 抓取指引
```markdown
@@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
#### Token 影响
每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。
每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态不仅会注册或移除抓取区段,也会更改搜索指引。
#### KV Cache 影响
只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。
只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。
### 工具 schema

View File

@@ -84,6 +84,8 @@ export function apply(ctx: Context, config: Config): void {
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars)
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs)
if (resolved.search) {
applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs, resolved.fetch)
}
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars)
}

View File

@@ -204,12 +204,21 @@ export function presentSearchResult(args: { query: string }, result: ToolResult)
* request's `maxResults`.
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
* @param fetchEnabled - whether the same composition exposes `web_fetch`, which
* controls whether search guidance may recommend that follow-up tool.
*/
export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: number): void {
export function applyWebSearchTool(
ctx: Context,
maxResults: number,
timeoutMs: number,
fetchEnabled: boolean,
): void {
ctx.systemPrompt.section({
name: 'tool:web_search',
order: 110,
text: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.',
text: fetchEnabled
? 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.'
: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.',
})
ctx.tools.register(defineTool({

View File

@@ -483,8 +483,17 @@ describe('tool-web registration', () => {
const { fiber, ctx } = await mountTools()
const prompt = await ctx.systemPrompt.assemble()
const text = prompt.sections.map(s => s.text).join('\n')
expect(text).toContain('web_search')
expect(text).toContain('web_fetch')
expect(text).toContain('Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.')
expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL')
await fiber.dispose()
})
it('does not advertise web_fetch in search-only prompt guidance', async () => {
const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
const prompt = await ctx.systemPrompt.assemble()
const text = prompt.sections.map(s => s.text).join('\n')
expect(text).toContain('Use the returned source snippets when available')
expect(text).not.toContain('web_fetch')
await fiber.dispose()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md
README.md: 54eb7561b9d81a9e2da565e3870abe094dbe984d
README.zh.md: 8862b8a9c1ba69d247942bb6e41289214826c682
README.md: 9046934de209ed0787efa50332e5be16bfdf55c6
README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`.
This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
This is an **implementation** package: it registers a provider into `ctx.web`, resolves its credential for each search through the optional `ctx.credentials` seam, records the auxiliary request in the initiating Agent session when one exists, and does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
## How it differs from a dedicated search endpoint
@@ -12,13 +12,14 @@ Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead
**Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable.
It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses.
It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. A mounted credentials service is authoritative; without one, the provider falls back to the launching process environment. The reference is resolved for each search, so a key stored or rotated by the Web Models page reaches the next call without a restart.
## Config
| Key | Default | Meaning |
|---|---|---|
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
| `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
| `model` | `deepseek-v4-flash` | Anthropic-format model name. |
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
@@ -29,7 +30,7 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
```
@@ -41,6 +42,10 @@ Results are deduplicated by URL because one request may surface the same page ac
Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
## Request logging
Immediately before dispatch, a search running under an initiating Agent appends the log-only `web/deepseek-search-llm-request` session event. It contains the resolved endpoint, API version, and exact secret-free JSON body sent to DeepSeek; headers and credentials are excluded. Credential failures and cancellations before dispatch create no event, while later HTTP or response failures leave the attempted request durable. Direct programmatic provider calls outside an Agent have no initiating session to log.
## Model Experience
### Auxiliary DeepSeek search request
@@ -61,7 +66,7 @@ Independent of the conversation request cache. The auxiliary instruction and nat
#### What the model sees
Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures are `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures include the actionable missing-credential message, `DeepSeek search credential resolution failed: <error>`, `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
#### Token effect
@@ -74,6 +79,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **One search costs a full Messages model turn** — latency plus generated tokens, with up to `maxUses` server-side searches; DeepSeek exposes no dedicated retrieval endpoint.
- **Dynamic credential availability resolves inside the operation** — the synchronous `available()` contract can establish that a resolver exists but cannot query an asynchronous credential store. A selected keyless provider therefore fails the search with `WEB_PROVIDER_CREDENTIAL_MISSING`; the stable `web_search` schema remains registered. Caller cancellation races this preflight locally, but cannot force an arbitrary credential backend itself to stop work.
- **Over-returned sources still cost tokens** — with no result-count knob on the wire, `maxResults` is enforced only post-hoc by seam truncation.
- **Uncited results carry no `snippet`** — a source gains one only when a `text` block citation (`cited_text`) matches its URL.
- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.

View File

@@ -4,7 +4,7 @@
由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。
这是一个**实现**包(package):它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。
这是一个**实现**包(package):它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,若存在发起请求的 agent(智能体)会话,还会在其中记录该辅助请求,且不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。
## 与专用搜索端点的区别
@@ -12,13 +12,14 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
**严格模式**:如果响应不含 `web_search_tool_result` 块(未触发原生搜索),提供方会抛出 `WebError` `WEB_PROVIDER_ERROR`,而非降级为文本抓取;这种行为诚实且可诊断。
它复用 `$DEEPSEEK_API_KEY`(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。
它复用 `DEEPSEEK_API_KEY` 凭据引用(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。已挂载的凭据服务具有权威性;没有该服务时,提供方会回退到启动进程的环境变量。每次搜索都会解析该引用,因此在 Web 的 Models 页中存储或轮换的密钥无需重启,即可用于下一次调用。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API 密钥。为空或缺失时提供方不可用。同时通过 `x-api-key` 和 `Authorization: Bearer` 发送(分别用于官方接口与 Anthropic 兼容代理)。 |
| `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 |
| `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 |
| `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 |
@@ -29,7 +30,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
```
@@ -41,6 +42,10 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
提供方失败变为 `WEB_PROVIDER_ERROR`;调用方取消变为 `WEB_ABORTED`。HTTP 重定向会在接触 `Location` 目标前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。
## 请求日志
由 agent 发起的搜索会在发出请求前一刻,向相应会话追加仅用于日志的 `web/deepseek-search-llm-request` 会话事件。其中包含已解析端点、API 版本,以及发送给 DeepSeek 且不含密钥的精确 JSON 请求体;不包含标头和凭据。发出请求前发生凭据处理失败或取消时不会创建事件;发出请求后才发生 HTTP 或响应失败时,本次请求尝试仍保留持久记录。在 agent 之外通过程序直接调用提供方时,没有发起会话可供记录。
## 模型体验
### 辅助 DeepSeek 搜索请求
@@ -61,7 +66,7 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
#### 模型看到的内容
通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息为 `DeepSeek search aborted`、`DeepSeek search request failed: <error>`、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: <error>`;HTTP 失败保留提供方消息。错误包装属于消费方。
通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: <error>`、`DeepSeek search aborted`、`DeepSeek search request failed: <error>`、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: <error>`;HTTP 失败保留提供方消息。错误包装属于消费方。
#### Token 影响
@@ -74,6 +79,6 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
## 已知限制与暂缓事项
- **一次搜索需要完整的 Messages 模型轮次**:会产生延迟与生成 token,并且最多执行 `maxUses` 次服务器侧搜索;DeepSeek 不公开专用检索端点。
- **动态凭据的可用性在操作内部解析**:同步的 `available()` 契约可以确认解析器存在,但无法查询异步凭据存储。因此,选中的无密钥提供方会使搜索以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;稳定的 `web_search` schema 仍保持注册。调用方取消在本地与该预检存在竞态,但无法强制任意凭据后端自行停止工作。
- **超量返回的源仍消耗 token**:协议没有结果数量旋钮,`maxResults` 只能由 seam 在事后截断。
- **未引用的结果没有 `snippet`**:只有 `text` 块中的引用(`cited_text`)匹配其 URL 时,源才会获得 snippet。
- **中止分类基于错误结构**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。

View File

@@ -27,7 +27,10 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -35,7 +38,11 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -7,6 +7,9 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
@@ -26,7 +29,7 @@ export {
DEEPSEEK_DEFAULT_MODEL,
DEEPSEEK_PROVIDER_ID,
} from './provider.ts'
export type { DeepSeekSearchProviderOptions } from './provider.ts'
export type { DeepSeekSearchLlmRequest, DeepSeekSearchProviderOptions } from './provider.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'web-search-deepseek'
@@ -34,10 +37,14 @@ export const name = 'web-search-deepseek'
/** The web seam this provider registers into. */
export const inject = ['web']
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
export interface Config {
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
/** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
apiKey?: string
/** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Anthropic-compatible endpoint base; `/messages` is appended. */
baseURL?: string
/** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
@@ -51,7 +58,8 @@ export interface Config {
}
export const Config: z<Config> = z.object({
apiKey: z.string(),
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
baseURL: z.string(),
model: z.string(),
apiVersion: z.string(),
@@ -63,12 +71,29 @@ export const Config: z<Config> = z.object({
export function apply(ctx: Context, config: Config): void {
const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS
const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV)
const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0
? config.apiKey
: undefined
ctx.web.registerSearchProvider(new DeepSeekSearchProvider({
apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '',
...literalApiKey === undefined ? {} : { apiKey: literalApiKey },
resolveApiKey: async () => {
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
const ambient = process.env[apiKeyEnv]
return ambient !== undefined && ambient.length > 0 ? ambient : undefined
},
apiKeyEnv,
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
maxTokens,
maxUses,
recordRequest: (request) => {
ctx.get('agents')?.currentInitiator()?.session.append(
'web/deepseek-search-llm-request',
request,
)
},
}))
}

View File

@@ -15,8 +15,9 @@ export const name = 'web-search-deepseek-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
* No runtime invariant: the package emits a pre-dispatch log event but owns no
* later authoritative dispatch event to relate it to. Exact envelope equality
* is pinned at the provider boundary instead.
*/
const install: InvariantInstaller = () => {}

View File

@@ -13,6 +13,8 @@ import type {
WebSearchResult,
WebSearchSource,
} from '@deepseek-ai/dsh-web'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-session'
import type {
AnthropicError,
AnthropicResponse,
@@ -47,10 +49,49 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
/**
* Exact secret-free DeepSeek Messages request recorded immediately before one
* auxiliary search dispatch.
*/
export interface DeepSeekSearchLlmRequest {
/** Fully resolved Messages endpoint. */
readonly endpoint: string
/** `anthropic-version` header value. */
readonly apiVersion: string
/** Exact JSON body sent to the provider. */
readonly body: {
readonly model: string
readonly max_tokens: number
readonly messages: readonly [{
readonly role: 'user'
readonly content: readonly [{
readonly type: 'text'
readonly text: string
}]
}]
readonly tools: readonly [{
readonly type: 'web_search_20250305'
readonly name: 'web_search'
readonly max_uses: number
}]
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Secret-free auxiliary DeepSeek search request recorded before dispatch. */
'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
}
}
/** Resolved provider options (the plugin's `apply` supplies credential and constant defaults). */
export interface DeepSeekSearchProviderOptions {
/** DeepSeek API key. Empty/absent makes the provider unavailable. */
apiKey: string
/** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */
apiKey?: string
/** Resolve the current DeepSeek API key for one search operation. */
resolveApiKey?: () => Promise<string | undefined>
/** Credential reference named by missing-credential diagnostics. */
apiKeyEnv?: CredentialRef
/** Endpoint base; `/messages` is appended. */
baseURL: string
/** Anthropic-format model name. */
@@ -61,6 +102,11 @@ export interface DeepSeekSearchProviderOptions {
maxTokens: number
/** Maximum `web_search` server-tool uses per request. */
maxUses: number
/**
* Record the exact secret-free request immediately before dispatch. A throw
* prevents dispatch so model-visible auxiliary input cannot escape logging.
*/
recordRequest?: (request: DeepSeekSearchLlmRequest) => void
}
/**
@@ -134,41 +180,51 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
constructor(private readonly options: DeepSeekSearchProviderOptions) {}
available(): boolean {
return this.options.apiKey.length > 0
return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined)
&& URL.canParse(this.options.baseURL)
&& isPositiveInteger(this.options.maxTokens)
&& isPositiveInteger(this.options.maxUses)
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const apiKey = await this.apiKey(signal)
throwIfSearchAborted(signal)
const endpoint = `${this.options.baseURL}/messages`
const body: DeepSeekSearchLlmRequest['body'] = {
model: this.options.model,
max_tokens: this.options.maxTokens,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
}
this.options.recordRequest?.({
endpoint,
apiVersion: this.options.apiVersion,
body,
})
throwIfSearchAborted(signal)
let response: Response
try {
response = await fetch(`${this.options.baseURL}/messages`, {
response = await fetch(endpoint, {
method: 'POST',
redirect: 'error',
headers: {
// Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy
// may expect `Authorization: Bearer` — send both so either resolves.
'x-api-key': this.options.apiKey,
'authorization': `Bearer ${this.options.apiKey}`,
'x-api-key': apiKey,
'authorization': `Bearer ${apiKey}`,
'anthropic-version': this.options.apiVersion,
'content-type': 'application/json',
'accept': 'application/json',
'user-agent': USER_AGENT,
},
body: JSON.stringify({
model: this.options.model,
max_tokens: this.options.maxTokens,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
}),
body: JSON.stringify(body),
...signal !== undefined ? { signal } : {},
})
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
@@ -183,7 +239,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
// into a generic HTTP-error message — cancellation is not a provider
// error (the seam's cancellation contract).
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
// Otherwise: the HTTP status is already captured in `message` above; a
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
// cost a richer provider message, never the real error.
@@ -195,11 +251,72 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
const payload = await response.json() as AnthropicResponse
return mapAnthropicResponse(payload)
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
if (error instanceof WebError) throw error
throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
}
/** Resolve one operation's credential without retaining it on the provider. */
private async apiKey(signal?: AbortSignal): Promise<string> {
throwIfSearchAborted(signal)
if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey
let resolved: string | undefined
try {
resolved = await abortable(this.options.resolveApiKey?.() ?? Promise.resolve(undefined), signal)
} catch (error: unknown) {
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
throw new WebError(
`DeepSeek search credential resolution failed: ${String(error)}`,
'WEB_PROVIDER_ERROR',
{ cause: error },
)
}
if (resolved !== undefined && resolved.length > 0) return resolved
const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
throw new WebError(
`DeepSeek search has no API key for "${ref}"; store it through the credentials service`
+ ' (the web Models page writes it), export it in the launching environment, or set a literal'
+ ' "apiKey" in the web-search-deepseek config',
'WEB_PROVIDER_CREDENTIAL_MISSING',
)
}
}
/**
* Race a same-process asynchronous preflight against caller cancellation. The
* attached settlement handlers keep observing an uncooperative operation after
* abort so a later rejection cannot become unhandled.
*/
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
if (signal === undefined) return operation
if (signal.aborted) return Promise.reject(searchAborted(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => { reject(searchAborted(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
void operation.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(new Error(String(error).replace(/^Error: /u, ''), { cause: error }))
},
)
})
}
/** Throw the provider's stable cancellation error when the caller already aborted. */
function throwIfSearchAborted(signal?: AbortSignal): void {
if (signal?.aborted === true) throw searchAborted(signal)
}
/** Build the provider's stable cancellation error while retaining the caller's reason. */
function searchAborted(signal?: AbortSignal, fallback?: unknown): WebError {
return new WebError('DeepSeek search aborted', 'WEB_ABORTED', {
cause: signal?.aborted === true ? signal.reason : fallback,
})
}
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */

View File

@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
import WebService from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
@@ -156,10 +161,11 @@ describe('DeepSeekSearchProvider availability', () => {
})
describe('DeepSeekSearchProvider request mapping', () => {
it('posts an Anthropic Messages request enabling the web_search server tool', async () => {
it('records and posts the same Anthropic Messages request with the web_search server tool', async () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
const recordRequest = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await new DeepSeekSearchProvider(options).search({ query: 'hello' })
await new DeepSeekSearchProvider({ ...options, recordRequest }).search({ query: 'hello' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages')
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
@@ -167,12 +173,20 @@ describe('DeepSeekSearchProvider request mapping', () => {
expect(headers['x-api-key']).toBe('ds-key')
expect(headers['authorization']).toBe('Bearer ds-key')
expect(headers['anthropic-version']).toBe('2023-06-01')
expect(JSON.parse(init.body as string)).toEqual({
const body = {
model: 'deepseek-chat',
max_tokens: 4096,
messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }],
}
expect(JSON.parse(init.body as string)).toEqual(body)
expect(recordRequest).toHaveBeenCalledOnce()
expect(recordRequest).toHaveBeenCalledWith({
endpoint: url,
apiVersion: '2023-06-01',
body,
})
expect(recordRequest.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder[0] ?? 0)
})
it('forwards the abort signal', async () => {
@@ -186,6 +200,91 @@ describe('DeepSeekSearchProvider request mapping', () => {
})
describe('DeepSeekSearchProvider error handling', () => {
it('does not start credential resolution or dispatch for a pre-aborted call', async () => {
const resolveApiKey = vi.fn(async () => 'late-key')
const recordRequest = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
await expect(new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey,
recordRequest,
}).search({ query: 'q' }, controller.signal))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
expect(resolveApiKey).not.toHaveBeenCalled()
expect(recordRequest).not.toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})
it('aborts while an uncooperative credential resolver remains pending', async () => {
const resolveApiKey = vi.fn(() => new Promise<string>(() => {}))
const recordRequest = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
const search = new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey,
recordRequest,
}).search({ query: 'q' }, controller.signal)
controller.abort(new Error('deadline'))
await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
expect(resolveApiKey).toHaveBeenCalledOnce()
expect(recordRequest).not.toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})
it('resolves credentials under an active cancellation signal', async () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
await expect(new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey: async () => 'resolved-key',
}).search({ query: 'q' }, controller.signal)).resolves.toMatchObject({ truncated: false })
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect((init.headers as Record<string, string>)['x-api-key']).toBe('resolved-key')
})
it('maps a credential resolver rejection under an active signal to WEB_PROVIDER_ERROR', async () => {
const controller = new AbortController()
await expect(new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey: () => Promise.reject(new Error('credential backend failed')),
}).search({ query: 'q' }, controller.signal))
.rejects.toThrow(expect.objectContaining({
code: 'WEB_PROVIDER_ERROR',
message: 'DeepSeek search credential resolution failed: Error: credential backend failed',
}))
})
it('uses the default credential reference when no resolver is configured', async () => {
await expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).search({ query: 'q' }))
.rejects.toThrow('DeepSeek search has no API key for "DEEPSEEK_API_KEY"')
})
it('observes cancellation triggered synchronously by credential resolution', async () => {
const controller = new AbortController()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey: () => {
controller.abort(new Error('resolver cancelled caller'))
return Promise.resolve('unused-key')
},
}).search({ query: 'q' }, controller.signal))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
expect(fetchMock).not.toHaveBeenCalled()
})
it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
@@ -216,6 +315,17 @@ describe('DeepSeekSearchProvider error handling', () => {
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps a custom abort reason to WEB_ABORTED', async () => {
const controller = new AbortController()
vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => { reject(new Error('custom abort reason')) }, { once: true })
})))
const search = new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
controller.abort(new Error('timeout reason'))
await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
@@ -323,28 +433,66 @@ describe('web-search-deepseek plugin registration', () => {
vi.stubGlobal('fetch', fetchMock)
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const fiber = await ctx.plugin(deepseekPlugin, {})
deepseekPlugin.apply(ctx, {})
await ctx.web.search({ query: 'q' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
expect((init.headers as Record<string, string>)['x-api-key']).toBe('env-key')
expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' })
await fiber.dispose()
await ctx.fiber.dispose()
} finally {
if (prev === undefined) delete process.env.DEEPSEEK_API_KEY
else process.env.DEEPSEEK_API_KEY = prev
}
})
it('is unavailable when neither config nor env supplies a key', async () => {
it('resolves the credential for each search so a stored or rotated key needs no restart', async () => {
const previous = process.env.DEEPSEEK_API_KEY
delete process.env.DEEPSEEK_API_KEY
const dir = await mkdtemp(join(tmpdir(), 'dsh-web-search-credentials-'))
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
const ctx = new Context()
try {
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' })
await expect(ctx.web.search({ query: 'missing' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }))
const ref = credentialRef('DEEPSEEK_API_KEY')
await ctx.credentials.set(ref, 'stored-key')
await ctx.web.search({ query: 'stored' })
await ctx.credentials.set(ref, 'rotated-key')
await ctx.web.search({ query: 'rotated' })
const headers = fetchMock.mock.calls.map(([, init]) => (init as RequestInit).headers as Record<string, string>)
expect(headers.map(value => value['x-api-key'])).toEqual(['stored-key', 'rotated-key'])
} finally {
await ctx.fiber.dispose()
await rm(dir, { recursive: true, force: true })
if (previous === undefined) delete process.env.DEEPSEEK_API_KEY
else process.env.DEEPSEEK_API_KEY = previous
}
})
it('reports an actionable credential error when neither config nor env supplies a key', async () => {
const prev = process.env.DEEPSEEK_API_KEY
delete process.env.DEEPSEEK_API_KEY
try {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await ctx.plugin(deepseekPlugin, {})
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
let caught: unknown
try {
await ctx.web.search({ query: 'q' })
} catch (error: unknown) {
caught = error
}
expect(caught).toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' })
if (!(caught instanceof Error)) throw new Error('search did not throw an Error')
expect(caught.message).toMatch(/store it through the credentials service.*Models page/s)
} finally {
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
}

View File

@@ -20,6 +20,15 @@
{
"path": "../web"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../support/invariants"
}