Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/tests/llm-target.spec.ts
#	packages/core/session/tests/request-header.spec.ts
This commit is contained in:
_Kerman
2026-07-27 16:44:06 +08:00
220 changed files with 4592 additions and 969 deletions

View File

@@ -13,6 +13,8 @@ import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession } from './skeleton/ConversationSession.tsx'
@@ -179,9 +181,16 @@ export function apply(ctx: Context): void {
// 'conversation.chat.toolview' declaration) is on the ledger.
ctx.plugin(ConversationService, { input: inputHub })
// The bash sample rides that exact seam, in third-party posture.
// The bash sample rides that exact seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
// The read-only queue dock entry (T9 file territory) rides the same
// registration seam into the input dock declared above.
ctx.plugin(queueDockEntry)

View File

@@ -37,17 +37,11 @@
border-radius: 6px;
}
/* Selection linkage: the selected call row wears the blue outline.
button-info-fill flips 500→400 with the theme, hitting the darker-blue
dark-mode spec exactly (business-primary stays 500 on both). */
.callRow[data-selected] {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 1px;
}
/* Selection still sets data-selected for details linkage; no outline
tool rows match Think chrome (no selected ring). */
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
the code turn reads as one unit; each nested row is itself a .callRow
(same components, same selection outline as top-level rows). */
the code turn reads as one unit; each nested row is itself a .callRow. */
.subCalls {
display: flex;
flex-direction: column;

View File

@@ -1,10 +1,11 @@
/* User bubble: right-aligned, figma r22 fill = the bubble specific token
(#EDF3FE light / dark pair rides the token sheet). */
/* User bubble: right-aligned column (bubble + IconActions). Figma
User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */
/* Block spacing is the flow column's gap alone — no extra padding here. */
.userRow {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.bubble {
@@ -19,6 +20,46 @@
color: var(--dsw-alias-label-primary);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
hover:none keeps actions visible (opacity:0 still hit-tests). */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.badge {
display: inline-block;
margin-bottom: 4px;

View File

@@ -1,14 +1,18 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned),
// steering (badged bubble), context injection and unknown-surface JSON rows.
// Props are frozen node slices off the snapshot cache; memo holds across
// streaming because unchanged nodes keep their references.
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
import { memo } from 'react'
import { memo, useCallback } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
JsonBlock, MessageText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css'
export interface MessageItemProps {
@@ -26,6 +30,35 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
el.remove()
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -58,15 +91,52 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
function UserActions({ text }: { text: string }) {
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
return (
<div className={css.actions}>
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
</div>
)
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
switch (node.kind) {
case 'user':
case 'user': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
<UserActions text={text} />
</div>
)
}
case 'steering': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{node.kind === 'steering' && <span className={css.badge}></span>}
<span className={css.badge}></span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>

View File

@@ -30,7 +30,7 @@ export function QueueDock({ useSession }: QueueDockProps) {
}
/**
* The dock entry as a plain registrant plugin (bash-sample posture).
* The dock entry as a plain registrant plugin (bash posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.

View File

@@ -323,7 +323,7 @@
.stopping,
.stopping:hover {
background: var(--dsw-alias-button-primary-dimmed);
color: var(--dsw-alias-brand-text);
color: var(--dsw-alias-label-primary);
}
.retry {

View File

@@ -0,0 +1,111 @@
/* Plan strip pinned above the composer: bordered card on the composer card's
axis (776px column inside 32px side padding). Colors resolve through
--dsw-alias-* tokens only; the active row rides the business blue, done
rows fade to tertiary. */
.root {
flex: none;
overflow: hidden;
margin: 8px auto 0;
width: calc(100% - 64px);
max-width: 776px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-base);
}
.header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
}
.header:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.title {
font-size: 13px;
line-height: 16px;
font-weight: 510;
color: var(--dsw-alias-label-primary);
}
.progress {
font-size: 12px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
}
.activeHint {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 16px;
color: var(--dsw-alias-label-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
display: grid;
flex: none;
place-items: center;
margin-left: auto;
color: var(--dsw-alias-label-secondary);
}
.list {
margin: 0;
padding: 0 12px 8px;
list-style: none;
max-height: 180px;
overflow-y: auto;
}
.item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 2px 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.glyph {
flex: none;
width: 14px;
text-align: center;
color: var(--dsw-alias-label-tertiary);
}
.item[data-status='completed'] .content {
color: var(--dsw-alias-label-tertiary);
text-decoration: line-through;
}
.item[data-status='completed'] .glyph {
color: var(--dsw-alias-state-success-primary);
}
.item[data-status='in_progress'] .content {
font-weight: 510;
color: var(--dsw-alias-label-primary);
}
.item[data-status='in_progress'] .glyph {
color: var(--dsw-alias-state-business-primary);
}
.content {
min-width: 0;
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,87 @@
// TodoPanel: persistent plan strip above the composer (the web counterpart
// of the TUI plan panel). Renders the latest todo/write whole-list snapshot —
// no data of its own, hidden while the list is empty. Mounted through the
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
// the selecting, so the panel takes the plain list and stays framework-free.
import { useState } from 'react'
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TodoPanel.module.css'
export interface TodoPanelProps {
/** The session's current plan (empty renders nothing) — selected by the dock adapter. */
todos: readonly TodoItem[]
}
/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */
const STATUS_GLYPHS: Record<TodoItem['status'], string> = {
completed: '✓', in_progress: '●', pending: '○',
}
export function TodoPanel({ todos }: TodoPanelProps) {
const [collapsed, setCollapsed] = useState(false)
if (todos.length === 0) return null
const done = todos.filter(t => t.status === 'completed').length
const active = todos.find(t => t.status === 'in_progress')
return (
<section className={css.root} data-testid="todo-panel" aria-label="任务清单">
<button
type="button"
className={css.header}
aria-expanded={!collapsed}
onClick={() => { setCollapsed(v => !v) }}
>
<span className={css.title}>Plan</span>
<span className={css.progress}>{done}/{todos.length}</span>
{collapsed && active !== undefined && (
<span className={css.activeHint}>{active.content}</span>
)}
<span className={css.chevron} aria-hidden>
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
</span>
</button>
{!collapsed && (
<ul className={css.list}>
{todos.map(item => (
<li key={item.content} className={css.item} data-status={item.status}>
<span className={css.glyph} aria-hidden>{STATUS_GLYPHS[item.status]}</span>
<span className={css.content}>{item.content}</span>
</li>
))}
</ul>
)}
</section>
)
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */
export function TodoDock({ useSession }: TodoDockProps) {
const todos = useSession(s => s.todos)
return <TodoPanel todos={todos} />
}
/**
* The plan strip as a plain registrant plugin (QueueDock posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.
*/
export const todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots', 'conversation'],
/**
* Register the plan strip into the input dock (list entry, above the queue rows).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
},
}

View File

@@ -1,29 +1,32 @@
/* Sample bash rows: deliberately distinct from ToolRow so the differential
registry hit is visible at a glance. */
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
.row {
.root {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
font-family: var(--ds-font-family-code);
font-size: 13px;
}
.row:hover {
.root:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.prompt {
.leading {
flex: none;
color: var(--dsw-alias-state-success-primary);
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.scopeBadge {
flex: none;
margin-right: 8px;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
@@ -32,17 +35,38 @@
background: var(--dsw-alias-state-business-primary);
}
.command {
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
}
.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;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,35 +1,54 @@
// Bash toolview sample, written in third-party posture: everything below uses
// only the public slot surface (ctx.slots.register into the keyed
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
// that a plain plugin can take over a tool row with zero dedicated machinery.
// Session-dimension differentiation happens INSIDE the component (the
// canonical sub-agent scenario): rows in child sessions render the scoped
// variant, derived from the standard useSessions kit — no registry predicates.
// Bash toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
// Child sessions keep a scoped badge so session-dimension differentiation stays
// observable inside the component (no parallel registry).
import type { Context } from 'cordis'
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
/** Bash row: command-first monospace summary replacing the generic card.
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
* the differential stays observable per session from one registration. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconApiOutline14 size={16} />
}
}
/** 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
}
}
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
if (isChild) {
return (
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
const status = stateStatus(model.state)
return (
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
<span className={css.prompt} aria-hidden>$</span>
<span className={css.command}>{model.summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
<div
className={css.root}
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
data-clickable
onClick={openDetails}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
</div>
)
}

View File

@@ -0,0 +1,42 @@
/* todo_write plan-update row: title + progress summary on one line. */
.row {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.badge {
flex: none;
color: var(--dsw-alias-state-business-primary);
}
.title {
flex: none;
font-weight: 510;
color: var(--dsw-alias-label-primary);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
}
.err {
flex: none;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
}

View File

@@ -0,0 +1,93 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
// summarizes the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line.
import type { KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './todo-row.module.css'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
function isItem(value: unknown): value is TodoWriteItem {
return typeof value === 'object' && value !== null
}
function summarize(argsRaw: string): string | null {
let parsed: unknown
try {
parsed = JSON.parse(argsRaw)
} catch {
// Mid-stream truncation or malformed model JSON: fall back to the generic summary.
return null
}
// Valid JSON with an invalid shape (null root, non-array todos, null items —
// a rejected tool/call retains such args verbatim): same generic fallback.
if (typeof parsed !== 'object' || parsed === null) return null
const todos = (parsed as { todos?: unknown }).todos
if (!Array.isArray(todos) || !todos.every(isItem)) return null
const done = todos.filter(t => t.status === 'completed').length
const active = todos.find(t => t.status === 'in_progress')
const head = `${done}/${todos.length} 已完成`
return typeof active?.content === 'string' && active.content !== ''
? `${head} · ${active.content}`
: head
}
/** One-line plan update row (click opens the raw args in details). Non-ok
* execution states keep the generic row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
// Button semantics, not a <button>: the row carries inline spans a button
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
openDetails()
}
return (
<div
className={css.row}
data-sample="todo-row"
data-state={model.state}
role="button"
tabIndex={0}
onClick={openDetails}
onKeyDown={openFromKeyboard}
>
{model.state === 'ok'
? <span className={css.badge} aria-hidden></span>
: <StateDot state={model.state === 'running' ? 'ongoing' : model.state === 'stopped' ? 'warning' : 'error'} />}
<span className={css.title}></span>
<span className={css.summary}>{summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
{model.state === 'stopped' && <span className={css.err}></span>}
</div>
)
}
/**
* The todo row as a plain registrant plugin, riding the same load-order seam
* as the bash sample: `inject: ['conversation']` guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is on the ledger.
*/
export const todoToolview = {
name: 'todo-toolview',
inject: ['slots', 'conversation'],
/**
* Register the todo 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: 'todo_write' }, TodoRow)
},
}