Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector

This commit is contained in:
imccyu
2026-07-27 16:16:17 +08:00
69 changed files with 964 additions and 419 deletions

View File

@@ -181,7 +181,8 @@ 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).

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

@@ -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

@@ -1,11 +1,12 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
// machinery specs since the tool ring dissolved into renderSlot.)
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
// with the keyed-slot machinery specs since the tool ring dissolved into
// renderSlot.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,7 +19,75 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx
afterEach(cleanup)
describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'hello bubble' }] as never,
} as never}
/>,
)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const exec = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: exec,
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'fallback body' }] as never,
} as never}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(exec).toHaveBeenCalledWith('copy')
})
it('user copy stays quiet when execCommand throws or is absent', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'quiet' }] as never,
} as never}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: undefined,
})
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
const view = render(
<MessageItem node={{
kind: 'steering', seq: 2, turn: 1, source: null,
@@ -29,6 +98,7 @@ describe('MessageItem arms', () => {
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
it('context and unknown nodes render their JSON rows', () => {

View File

@@ -157,12 +157,13 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(view.getByText('List the notes directory')).toBeTruthy()
// Nested rows are ALWAYS visible (no parent expand needed): the bash
// sub-call landed in the bash sample plugin's keyed registration — the
// exact component a native top-level bash row uses — and the unregistered
// sub-call landed in the bash sample plugin's keyed registration — Bash ·
// description chrome, same as a top-level bash row — and the unregistered
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
})

View File

@@ -169,17 +169,19 @@ describe('bash sample row', () => {
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('summarizes the command and hands clicks to openDetails on both arms', () => {
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
const openGlobal = vi.fn()
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Bash')
expect(globalRow.textContent).toContain('Build')
fireEvent.click(globalRow)
expect(openGlobal).toHaveBeenCalledTimes(1)
const openScoped = vi.fn()
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Bash')
expect(scopedRow.textContent).toContain('Build')
fireEvent.click(scopedRow)
expect(openScoped).toHaveBeenCalledTimes(1)

View File

@@ -157,6 +157,7 @@ describe('keyed toolview hole through the real machinery', () => {
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.
expect(view.getByText('Tool call')).toBeTruthy()

View File

@@ -264,7 +264,7 @@ describe('ChatView', () => {
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => {
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('run a'))

View File

@@ -1,13 +1,13 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// PendingCard question arm, bash sample error pill, the node-half empty
// PendingCard question arm, bash sample state dots, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -76,14 +76,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows the failed pill on error results (root session arm)', () => {
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
}
// Root session (no parentId): the global arm renders, error pill visible.
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
@@ -91,12 +84,40 @@ describe('tails', () => {
current: undefined,
phase: 'ready',
} as SessionListState)
const props = {
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps
const view = render(<BashRow {...props} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('failed')).toBeTruthy()
} as unknown as ToolRowProps)
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, callView: null,
}
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
}
const stoppedResult: ToolResultNode = {
...errorResult,
error: { name: 'E', code: 'interrupted' },
}
const runningView = render(<BashRow {...props(running)} />)
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(runningView.getByText('Bash')).toBeTruthy()
expect(runningView.getByText('List')).toBeTruthy()
runningView.unmount()
const errorView = render(<BashRow {...props(errorResult)} />)
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(errorView.getByText('失败')).toBeTruthy()
errorView.unmount()
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stoppedView.getByText('已停止')).toBeTruthy()
})
})

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
README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d
README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7
README.md: 58e450451ab64f69762817dfb277b8a888e2177f
README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d

View File

@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Model Experience

View File

@@ -6,7 +6,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
## 模型体验

View File

@@ -1,13 +1,79 @@
/* One code-block geometry for highlighted and plain arms: the shiki <pre>
and the fallback <pre> draw identically except for token colors. */
/* Visual baseline: deepsuite `@deepseek/md` code-block.css. Highlight colors
stay on the existing shiki `--shiki-*` sheet (not Prism highlight.css). */
.block {
--dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner);
--dsl-code-block-border-radius: 12px;
--dsl-code-block-banner-font: var(--dsw-font-xs-13);
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-code-block-border-radius);
}
.block:not(:last-child) {
margin-bottom: 11px;
}
.bannerWrap {
position: sticky;
top: 0;
z-index: 6;
background-color: var(--dsw-alias-bg-base);
border-top-left-radius: var(--dsl-code-block-border-radius);
border-top-right-radius: var(--dsl-code-block-border-radius);
}
.banner {
background: var(--dsl-code-block-banner-background-color);
padding: 9px 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font: var(--dsl-code-block-banner-font);
border-top-left-radius: var(--dsl-code-block-border-radius);
border-top-right-radius: var(--dsl-code-block-border-radius);
}
.infostring {
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
}
.copyButton {
background-color: rgb(255 255 255 / 0);
border: none;
padding: 0;
margin: 0;
color: inherit;
cursor: pointer;
font: inherit;
}
.block :where(pre) {
margin: 0;
padding: 8px 10px;
border-radius: 8px;
font: var(--dsl-code-block-content-font);
padding: 16px;
margin: 0 !important;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
}
/* Shiki inlines its theme background var; route it to the repo token. */
@@ -23,5 +89,4 @@
.plain {
color: var(--dsw-alias-label-primary);
white-space: pre;
}

View File

@@ -1,12 +1,10 @@
// CodeBlock: one code surface for every consumer — markdown fences, the
// run_code program body, and the details panel's raw args/output — with
// shiki highlighting for the registered grammars and an identical-geometry
// plain fallback for everything else. Shiki emits a single <pre class="shiki">
// tree of nested spans whose colors are --shiki-* custom properties
// (token sheets own the values); it produces no scripts or event handlers,
// so injecting its output is safe by construction.
// plain fallback for everything else. Chrome (language banner + copy) matches
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
import { useMemo } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { highlightToHtml } from './highlight.ts'
import css from './CodeBlock.module.css'
@@ -20,18 +18,80 @@ export interface CodeBlockProps {
className?: string | undefined
}
/** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
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 {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
if (html === undefined) {
return (
<div className={clsx(css.block, className)}>
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
/* v8 ignore next -- both arms always mount a <pre>; trimmed is the
typed fallback if the DOM shape ever diverges. */
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => setCopied(false), 1000)
})
}, [copied, trimmed])
const body = html === undefined
? (
<pre className={css.plain}><code>{trimmed}</code></pre>
)
: (
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
<div dangerouslySetInnerHTML={{ __html: html }} />
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
<div className={css.bannerWrap}>
<div className={css.banner}>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
</div>
</div>
</div>
)
}
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
return <div className={clsx(css.block, className)} dangerouslySetInnerHTML={{ __html: html }} />
{body}
</div>
)
}

View File

@@ -1,95 +1,168 @@
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
intentionally absent (no matching DOM). Token names match that sheet. */
.markdown {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
overflow-wrap: anywhere;
font: var(--dsw-font-markdown-base);
color: var(--dsw-alias-label-primary);
}
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
margin: 0;
.markdown strong {
font-weight: 600;
}
.markdown h1 {
font: var(--dsw-font-markdown-h1);
margin: 32px 0 16px;
}
.markdown h2 {
font: var(--dsw-font-markdown-h2);
margin: 32px 0 16px;
}
.markdown h3 {
font: var(--dsw-font-markdown-h3);
margin: 32px 0 16px;
}
.markdown :where(h4, h5, h6) {
.markdown h4 {
font: var(--dsw-font-markdown-h4);
margin: 16px 0;
}
.markdown :where(strong, th) {
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
.markdown :where(h5, h6) {
font: var(--dsw-font-markdown-base-strong);
margin: 16px 0;
}
.markdown :where(ul, ol) {
padding-inline-start: 24px;
.markdown :where(h1, h2, h3, h4, h5, h6) strong {
font-weight: inherit;
}
.markdown li + li {
margin-block-start: 4px;
.markdown p {
margin: 16px 0;
}
.markdown li > :where(ul, ol) {
margin-block-start: 4px;
/* Tighten h4–h6 against a following list (design: 8px gap). */
.markdown :where(h4, h5, h6) + :where(ul, ol) {
margin-top: 8px;
}
.markdown blockquote {
padding-inline-start: 12px;
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
color: var(--dsw-alias-label-secondary);
.markdown :where(h4, h5, h6):has(+ :where(ul, ol)) {
margin-bottom: 8px;
}
.markdown a {
/* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet
keeps design-platform brand-text as near-black, so links use the blue
business-primary alias instead. */
color: var(--dsw-alias-state-business-primary);
text-decoration: underline;
text-underline-offset: 2px;
transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out);
position: relative;
text-decoration: none;
/* Transparent hit-area padding; literal zero-alpha only (no painted color). */
border-left: 3px solid rgb(255 255 255 / 0);
border-right: 3px solid rgb(255 255 255 / 0);
border-top: 2px solid rgb(255 255 255 / 0);
border-bottom: 2px solid rgb(255 255 255 / 0);
margin-left: -3px;
margin-right: -3px;
}
.markdown :not(pre) > code {
padding: 2px 4px;
border-radius: 4px;
background: var(--dsw-alias-markdown-inline-code);
font: var(--dsw-font-markdown-code);
.markdown a:hover,
.markdown a:focus {
outline: none;
text-decoration: underline var(--dsw-alias-state-business-primary);
}
.markdown pre {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 12px 16px;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
.markdown a:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
}
.markdown pre code {
padding: 0;
background: transparent;
font: inherit;
overflow-wrap: normal;
word-break: normal;
white-space: pre;
.markdown :where(ul, ol) {
margin: 16px 0;
padding-left: 18px;
}
.markdown li:not(:first-child) {
margin-top: 6px;
}
.markdown li > :where(ul, ol) {
margin-top: 4px;
}
.markdown li::marker {
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */
.markdown :where(ul, ol) ol {
list-style-position: inside;
padding-left: 0;
}
.markdown :where(ul, ol) ol li p {
display: inline;
}
.markdown li > p {
margin: 8px 0;
}
.markdown li > *:first-child {
margin-top: 0;
}
/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */
.markdown li > *:last-child:not(:global(.md-code-block)) {
margin-bottom: 0;
}
.markdown hr {
width: 100%;
border: 0;
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
display: block;
border: none;
height: 1px;
margin: 32px 0;
background: var(--dsw-alias-border-l2);
}
.markdown blockquote {
border-left: 2px solid var(--dsw-alias-label-caption);
margin: 16px 0 0;
padding-left: 14px;
}
.markdown pre {
margin: 16px 0;
font-family: var(--ds-font-family-code);
overflow: auto;
}
.markdown :not(pre) > code {
display: inline-flex;
align-items: center;
box-sizing: border-box;
font: var(--dsw-font-markdown-code);
font-family: var(--ds-font-family-code);
font-size: 0.875em !important;
background-color: var(--dsw-alias-markdown-inline-code);
border-radius: 6px;
padding: 0 5px;
}
.markdown :where(h1, h2, h3, h4, h5, h6) code {
font: inherit;
font-family: var(--ds-font-family-code);
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-state-business-primary);
accent-color: var(--dsw-alias-label-secondary);
}
.tableScroll {
@@ -99,22 +172,52 @@
}
.tableScroll table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font: var(--dsw-font-markdown-table);
}
.tableScroll :where(th, td) {
padding: 6px 12px;
border: 1px solid var(--dsw-alias-markdown-citation);
text-align: start;
white-space: nowrap;
width: max-content;
max-width: max-content;
}
.tableScroll th {
background: var(--dsw-alias-markdown-code-block-banner);
text-align: start;
padding: 10px 16px;
border-bottom: 1px solid var(--dsw-alias-border-l3);
border-top: none;
font: var(--dsw-font-markdown-table-head);
max-width: 320px;
max-width: min(30vw, 320px);
min-width: 100px;
}
.tableScroll td {
padding: 10px 16px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
font: var(--dsw-font-markdown-table);
max-width: 320px;
max-width: min(30vw, 320px);
min-width: 100px;
}
.tableScroll th:first-child,
.tableScroll td:first-child {
padding-left: 0;
}
.tableScroll td:last-child {
padding-right: 0;
}
.tableScroll table code {
font-size: 13px;
}
.markdown > *:first-child,
.markdown p:first-child {
margin-top: 0 !important;
}
.markdown > *:last-child,
.markdown p:last-child {
margin-bottom: 0 !important;
}
.imageAlt {

View File

@@ -5,14 +5,17 @@
// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
// alongside the rest of the markdown family.
import { describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { highlightToHtml } from '../src/markdown/highlight.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
describe('highlightToHtml', () => {
it('highlights a registered grammar into css-variables token spans', () => {
const html = highlightToHtml('const x: number = 1', 'typescript')
@@ -50,4 +53,86 @@ describe('CodeBlock', () => {
expect(view.container.querySelector('pre.shiki')).toBeNull()
expect(view.getByText('plain text')).toBeTruthy()
})
it('shows the language banner and copies the pre textContent', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
expect(screen.getByText('ts')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('const a = 1')
// Flush the clipboard promise under fake timers before asserting the label.
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('does not claim success when clipboard.writeText rejects', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(<CodeBlock code="plain body" />)
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('falls back to execCommand when clipboard.writeText is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const exec = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: exec,
})
render(<CodeBlock code="plain body" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(exec).toHaveBeenCalledWith('copy')
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
})
it('does not claim success when execCommand throws or is absent', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
})
const denied = render(<CodeBlock code="plain body" />)
fireEvent.click(denied.getByRole('button', { name: '复制' }))
await Promise.resolve()
expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
denied.unmount()
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: undefined,
})
const absent = render(<CodeBlock code="plain body" />)
fireEvent.click(absent.getByRole('button', { name: '复制' }))
await Promise.resolve()
expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
})
})

View File

@@ -57,8 +57,10 @@ describe('MarkdownText', () => {
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
expect(container.querySelector('hr')).not.toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
// The ts fence routed through the shared CodeBlock: shiki token spans present.
// The ts fence routed through the shared CodeBlock: shiki token spans + banner.
expect(container.querySelector('pre.shiki')).not.toBeNull()
expect(screen.getByText('ts')).toBeTruthy()
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(container.querySelector('br')).not.toBeNull()
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()

View File

@@ -9,5 +9,7 @@
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ds-transition-duration: 0.2s;
--ds-transition-duration-fast: 0.1s;
--ds-transition-duration-slow: 0.3s;
}

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
README.md: 0278a4a582e535125d001e09736b89f13be72a0c
README.zh.md: e3e2b9559d69e4be10cd4d373bbda2dd47396b72
README.md: 13a04aa9f73fec5824069644449009989d6fd924
README.zh.md: 3e417c5f8be1f7831b99940c2a4aec815dc2c5b6

View File

@@ -9,7 +9,7 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` |
| `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) |
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-deepseek/` | DeepSeek API adapter (direct fetch + eventsource-parser SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) |
The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the interface or consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership.

View File

@@ -9,7 +9,7 @@ LLM seam 及其提供方适配器。接口包(`llm`)拥有抽象服务、内
| `llm/` | 抽象 LLM 服务 + 内容块词汇 + 分片组装器 | `ctx.llm` |
| `token-meter/` | 感知回放的请求与表层 token 测量 | `ctx.tokenMeter` |
| `llm-retry/` | 有界的暂时性请求重试策略 | (监听 `agent/request-error`) |
| `llm-deepseek/` | DeepSeek API 适配器(手写 fetch/SSE) | (注册到 `ctx.llm`) |
| `llm-deepseek/` | DeepSeek API 适配器(直接 fetch + eventsource-parser SSE) | (注册到 `ctx.llm`) |
| `llm-pi-ai/` | 通过 `@earendil-works/pi-ai` 实现的多提供方适配器 | (注册到 `ctx.llm`) |
接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器都是该分组下的扁平兄弟包。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。拥有路由的适配器可以解析精确的提供方/模型上下文容量;token 计量器仍与模型无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动接口或消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: 75b02f54ce25198c6aa41e510c9a5698d85e3d60
README.zh.md: 7c156f14de3c19f732b57df809ce2e07798f67ba
README.md: d610968bfd26cb553d36f363681774ce87039ab3
README.zh.md: df1e12705d37676bab631ba8a4b3c0951ead1a2e

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` + SSE translation from the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol.
DeepSeek chat-completions adapter for the harness LLM seam: direct `fetch` + SSE (framed by `eventsource-parser`) translating the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol.
A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE,将官方协议格式(真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。
harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE(由 `eventsource-parser` 分帧),将官方协议格式(真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。
同一 seam 的第二个库支持实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包始终拥有 `deepseek` 提供方路由;在同一上下文中装载 `provider: deepseek` 的 pi-ai profile 会按设计抛出 `LlmError('DUPLICATE_ADAPTER')`。

View File

@@ -33,6 +33,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"eventsource-parser": "^3.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -20,7 +20,7 @@ import { parseSse } from './sse.ts'
import { translate } from './translate.ts'
import type { WireError } from './types.ts'
/** One optional model entry advertised by the hand-written adapter. */
/** One optional model entry advertised by the direct-fetch adapter. */
export interface DeepSeekCatalogModel {
/** Wire model id accepted by the configured endpoint. */
id: string

View File

@@ -1,65 +1,33 @@
/**
* Decode an SSE byte stream into event `data` payloads. Network reads may split UTF-8 or lines;
* CRLF, comments, non-data fields, and multi-data events are handled per SSE rules. The literal
* `[DONE]` is yielded so the caller owns final flushing, and EOF before it raises {@link LlmError}.
* Decode an SSE byte stream into event `data` payloads. Framing — chunk
* reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping,
* multi-`data:` joining — is `eventsource-parser`'s; this module keeps only
* the DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns
* final flushing, and EOF before it raises {@link LlmError}. Framing is
* spec-strict: an event dispatches only on its blank-line terminator, so an
* unterminated tail at EOF is truncation, not a flushable payload.
*
* Minimal SSE (text/event-stream) parser for the chat-completions stream.
* @module dsh-llm-deepseek/sse
*/
import { EventSourceParserStream } from 'eventsource-parser/stream'
import { LlmError } from '@deepseek-ai/dsh-llm'
/** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */
export const DONE = '[DONE]'
/** Extract the joined data payload from one raw SSE event block. */
function eventData(block: string): string | undefined {
const data: string[] = []
for (const rawLine of block.split('\n')) {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
if (line.startsWith('data:')) {
// The spec strips ONE leading space after the colon.
data.push(line.startsWith('data: ') ? line.slice(6) : line.slice(5))
}
// Comments (':…') and other fields (event:, id:, retry:) are ignored.
}
if (data.length === 0) return undefined
return data.join('\n')
}
/**
* Parse a byte stream into SSE data payloads. Yields `[DONE]` as the final
* Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
* without it (truncated response — the model call cannot be trusted).
* @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence.
* @returns each event's data payload in arrival order, the `[DONE]` sentinel last.
*/
export async function* parseSse(stream: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
const decoder = new TextDecoder()
let buffer = ''
for await (const bytes of stream) {
buffer += decoder.decode(bytes, { stream: true })
// Events are separated by a blank line (\n\n; tolerate \r\n\r\n via the
// per-line \r strip in eventData and a normalized split here).
let boundary: number
while ((boundary = buffer.search(/\r?\n\r?\n/)) !== -1) {
const matched = /\r?\n\r?\n/.exec(buffer.slice(boundary))
const block = buffer.slice(0, boundary)
// matched cannot be null: search() just found the same pattern at 0.
buffer = buffer.slice(boundary + (matched as RegExpExecArray)[0].length)
const data = eventData(block)
if (data === undefined) continue
yield data
if (data === DONE) return
}
}
// Flush any final un-terminated event (servers usually end with \n\n, but
// a trailing block without one is still parseable).
buffer += decoder.decode()
const data = eventData(buffer)
if (data !== undefined) {
export async function* parseSse(stream: ReadableStream<BufferSource>): AsyncGenerator<string> {
const events = stream
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
for await (const { data } of events) {
yield data
if (data === DONE) return
}

View File

@@ -7,7 +7,7 @@ import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble, type AssembledResult } from './assemble.ts'
/**
* Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across
* Real-API e2e for the direct-fetch adapter: V4 Flash + V4 Pro across
* thinking modes and both official effort levels. Key-gated — skips
* entirely without $DEEPSEEK_API_KEY (see vitest.e2e.config.ts).
*/

View File

@@ -2,12 +2,21 @@ import { describe, expect, it } from 'vitest'
import { LlmError } from '@deepseek-ai/dsh-llm'
import { DONE, parseSse } from '../src/sse.ts'
/** Build a byte stream from string fragments (fragments = network reads). */
async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator<Uint8Array> {
/**
* DeepSeek protocol contract only: the [DONE] sentinel and STREAM_CLOSED on
* EOF without it. SSE framing (chunk splits, CRLF, multi-data joins, comments)
* is eventsource-parser's contract, not re-proven here.
*/
/** Build an SSE byte stream from string fragments (fragments = network reads). */
function bytes(...fragments: string[]): ReadableStream<Uint8Array<ArrayBuffer>> {
const encoder = new TextEncoder()
for (const fragment of fragments) {
yield typeof fragment === 'string' ? encoder.encode(fragment) : fragment
}
return new ReadableStream({
start(controller) {
for (const fragment of fragments) controller.enqueue(encoder.encode(fragment))
controller.close()
},
})
}
async function collect(stream: AsyncIterable<string>): Promise<string[]> {
@@ -17,57 +26,14 @@ async function collect(stream: AsyncIterable<string>): Promise<string[]> {
}
describe('parseSse', () => {
it('parses simple events and the DONE sentinel', async () => {
it('yields event payloads and the DONE sentinel', async () => {
const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('handles events split across reads at arbitrary positions', async () => {
const events = await collect(parseSse(bytes('da', 'ta: {"a"', ':1}\n', '\ndata: [DO', 'NE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('handles multi-byte UTF-8 split across reads', async () => {
const encoded = new TextEncoder().encode('data: {"text":"日本語"}\n\ndata: [DONE]\n\n')
// Split inside the 3-byte sequence for 日.
const splitAt = 16
const events = await collect(parseSse(bytes(encoded.slice(0, splitAt), encoded.slice(splitAt))))
expect(events).toEqual(['{"text":"日本語"}', DONE])
})
it('tolerates CRLF line endings', async () => {
const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata: [DONE]\r\n\r\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('joins multi-data events with newlines (SSE spec)', async () => {
const events = await collect(parseSse(bytes('data: line1\ndata: line2\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['line1\nline2', DONE])
})
it('ignores comments and non-data fields', async () => {
const events = await collect(parseSse(bytes(': keepalive\nevent: chunk\nid: 7\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('skips blocks without data fields', async () => {
const events = await collect(parseSse(bytes(': ping\n\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('preserves data lines without the optional space', async () => {
const events = await collect(parseSse(bytes('data:{"a":1}\n\ndata:[DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('parses several events from one read', async () => {
const events = await collect(parseSse(bytes('data: 1\n\ndata: 2\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['1', '2', DONE])
})
it('flushes a final un-terminated DONE at stream end', async () => {
const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))
expect(events).toEqual(['{"a":1}', DONE])
it('stops yielding after DONE even when more data follows', async () => {
const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n')))
expect(events).toEqual([DONE])
})
it('throws STREAM_CLOSED when the stream ends without DONE', async () => {
@@ -83,26 +49,10 @@ describe('parseSse', () => {
await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/)
})
it('stops yielding after DONE even when more data follows', async () => {
const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n')))
expect(events).toEqual([DONE])
})
})
describe('parseSse edge branches', () => {
it('handles a lone CR-terminated data line', async () => {
// Exercises the \r-strip branch on a line that is ONLY "data:…\r".
const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata:[DONE]\r\n\r\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('strips CR from non-data field lines too', async () => {
const events = await collect(parseSse(bytes('event: chunk\r\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['{"a":1}', DONE])
})
it('treats bare "data:" lines as empty payload entries', async () => {
const events = await collect(parseSse(bytes('data:\ndata: x\n\ndata: [DONE]\n\n')))
expect(events).toEqual(['\nx', DONE])
it('treats a final DONE missing its blank-line terminator as truncation', async () => {
// Spec-strict framing: an event dispatches only on its blank-line
// terminator, so an unterminated tail at EOF is STREAM_CLOSED — real
// providers always terminate events, so a missing terminator is truncation.
await expect(collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))).rejects.toThrow(/without \[DONE\]/)
})
})

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/llm/llm/README.md
README.md: e11b997cddaacaea7fec00f73b7f5b47c60a943d
README.zh.md: e074d7f764c130b4ea3df343f566b27ef3a5dd3d
README.md: 3efb3ece3caadeaceaa3c504ba4b10ddb951127a
README.zh.md: 4af8b8d08cc96ff0e36b10b15e1d86afd43004c9

View File

@@ -64,7 +64,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
### Real adapters
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
## Model Experience

View File

@@ -64,7 +64,7 @@
### 真实适配器
两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用手写 fetch/SSE,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。
两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。
## 模型体验

View File

@@ -121,8 +121,8 @@ export interface PreparedLlmCall {
/**
* Provider-wire adapter for the harness message and stream vocabulary. Register implementations
* with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
* `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled
* DeepSeek and pi-ai adapters intentionally exercise this contract through different internals.
* `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch
* DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals.
*/
export abstract class LlmAdapter {
/**

View File

@@ -1,5 +1,5 @@
/**
* Required hand-rolled DeepSeek and custom pi-ai provider behavior.
* Required direct-fetch DeepSeek and custom pi-ai provider behavior.
*
* @module @deepseek-ai/dsh-helper/features/builtin/provider
*/
@@ -74,7 +74,7 @@ export class ProviderFeature extends ExclusiveOptionFeature {
override readonly required = true
override readonly options = [new DeepSeekOption(), new CustomOption()]
/** Prefer the hand-rolled adapter and its public endpoint defaults. */
/** Prefer the direct-fetch adapter and its public endpoint defaults. */
override defaultOptions(): readonly string[] {
return ['deepseek']
}