feat(web): split context injection into producer-declared forms

Injected context reached the transcript as one anonymous shape whose
expanded body was the whole message serialized as inline JSON, so the
escaping collapsed the only readable part — the model-facing prose —
into a single line.

`MessageSource` gains an optional producer-declared `form`, a small
semantic vocabulary of information shapes independent of `kind`: kind
says who produced the context, form says what shape it is, so several
producers may share one presentation. Two values ship.

`instructions` (workspace-context) lists the reconciled files above
their text and keeps the `<system-reminder>` framing verbatim, because
the framing is part of what the model read. `catalog` moves dsh-tool-skill
off the shared plugin kind onto a `skill-catalog` source carrying the
published name/description entries, and the body lists those instead of
re-parsing `<available_skills>` out of the prose. Catalog identity moves
with it: the republish digest now covers the durable entries, deleting
the text-slicing that recovered them from a logged message.

Everything else renders the opaque body — the model-facing text with its
real line breaks, then the remaining provenance as fields. That is the
documented default, not a leftover: a resumed, forked, or foreign log
must render whether or not its producer is mounted here, which is why
the classification lives in the durable source rather than a client-side
table keyed by producer.
This commit is contained in:
creatixchu
2026-08-05 12:15:03 +08:00
parent e5d4ae72fc
commit 340c9ba576
51 changed files with 779 additions and 174 deletions

View File

@@ -0,0 +1,96 @@
/* Expanded context bodies: one code-block surface shared by every form, so the
disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */
.text {
margin: 0;
color: var(--dsw-alias-label-secondary);
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Provenance beneath the text: dimmer than the content it describes. */
.fields {
display: flex;
flex-direction: column;
gap: 2px;
margin: 8px 0 0;
padding-top: 8px;
border-top: 1px solid var(--dsw-alias-line-secondary);
}
.field {
display: flex;
gap: 8px;
min-width: 0;
}
.fieldKey {
flex: none;
min-width: 96px;
color: var(--dsw-alias-label-caption);
}
.fieldValue {
flex: 1 1 auto;
min-width: 0;
margin: 0;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
}
/* instructions: the reconciled files, above their text. */
.files {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.file {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.filePath {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.fileAction {
color: var(--dsw-alias-label-caption);
}
/* catalog: one row per published entry. */
.entries {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
gap: 8px;
min-width: 0;
}
.entryName {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.entryDescription {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,227 @@
// Expanded bodies for the context disclosure, one per durable context form.
// The producer declares the form; this module only chooses a presentation for
// it. Every form falls back to OpaqueBody, which is the documented default for
// an absent, unknown, or malformed form — a resumed or foreign log must render
// even when this UI version has never seen its producer.
import type { ReactNode } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ContextBody.module.css'
/** Model-facing text stays bounded at the disclosure, not at the producer. */
const MAX_CHARS = 20_000
type Translate = ChatViewSlotProps['t']
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/**
* Concatenated text of the content blocks, with the non-text blocks kept aside.
* Context is text in practice (every producer injects one text block), but the
* block union is merge-extensible, so an unknown block keeps its own fallback
* rather than vanishing.
*/
function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } {
const texts: string[] = []
const rest: unknown[] = []
for (const block of content) {
if (block.type === 'text') texts.push(block.text)
else rest.push(block)
}
return { text: texts.join('\n'), rest }
}
/** The model-facing text, truncated to the display bound. */
function boundedText(text: string, t: Translate): string {
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}
/** One source field rendered as a value row; nested shapes stay compact JSON. */
function fieldValue(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return JSON.stringify(value)
}
/**
* Provenance fields as a key/value list. `kind` is omitted because the row
* header already names the producer, and `form` because the presentation the
* reader is looking at IS that value.
*/
function SourceFields({ source }: { source: unknown }): ReactNode {
const record = asRecord(source)
if (record === null) return null
const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form')
if (rows.length === 0) return null
return (
<dl className={css.fields} data-context-fields>
{rows.map(([key, value]) => (
<div key={key} className={css.field}>
<dt className={css.fieldKey}>{key}</dt>
<dd className={css.fieldValue}>{fieldValue(value)}</dd>
</div>
))}
</dl>
)
}
/**
* Default presentation: the model-facing text as text, with its real line
* breaks, and the remaining provenance beneath it. This is what every form
* this UI version does not recognize renders as.
* @param props - Durable content, its source, and the locale seat.
* @returns The opaque context body.
*/
export function OpaqueBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const { text, rest } = partitionContent(content)
return (
<>
{text !== '' && <pre className={css.text} data-context-text>{boundedText(text, t)}</pre>}
{rest.map((block, index) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => t('json.truncated', { total })}
/>
))}
<SourceFields source={source} />
</>
)
}
/** One reconciled instruction file, as the durable source records it. */
interface InstructionChange {
action: string
path: string
digest?: string
}
/** Instruction changes read off the source; empty when the shape is unusable. */
function instructionChanges(source: unknown): InstructionChange[] {
const record = asRecord(source)
const list = record === null ? undefined : record['changes']
if (!Array.isArray(list)) return []
const changes: InstructionChange[] = []
for (const entry of list) {
const change = asRecord(entry)
if (change === null) continue
const path = change['path']
if (typeof path !== 'string' || path === '') continue
const action = change['action']
const digest = change['digest']
changes.push({
action: typeof action === 'string' ? action : '',
path,
...typeof digest === 'string' ? { digest } : {},
})
}
return changes
}
/**
* `instructions` form: the files this context reconciled, then their text.
*
* The text keeps its `<system-reminder>` framing verbatim — the framing is part
* of what the model read, so hiding it would misreport the request.
* @param props - Durable content, its source, and the locale seat.
* @returns The instructions context body.
*/
export function InstructionsBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const changes = instructionChanges(source)
const baseline = asRecord(source)?.['baseline'] === true
const { text, rest } = partitionContent(content)
return (
<>
{changes.length > 0 && (
<ul className={css.files} data-context-files>
{changes.map(change => (
<li key={change.path} className={css.file} title={change.digest}>
<span className={css.filePath}>{change.path}</span>
<span className={css.fileAction}>
{t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)}
</span>
</li>
))}
</ul>
)}
{text !== '' && <pre className={css.text} data-context-text>{boundedText(text, t)}</pre>}
{rest.map((block, index) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => t('json.truncated', { total })}
/>
))}
</>
)
}
/** One catalog entry, as the durable source records it. */
interface CatalogEntry {
name: string
description: string
}
/** Catalog entries read off the source; empty when the shape is unusable. */
function catalogEntries(source: unknown): CatalogEntry[] {
const record = asRecord(source)
const list = record === null ? undefined : record['entries']
if (!Array.isArray(list)) return []
const entries: CatalogEntry[] = []
for (const item of list) {
const entry = asRecord(item)
if (entry === null) continue
const name = entry['name']
if (typeof name !== 'string' || name === '') continue
const description = entry['description']
entries.push({ name, description: typeof description === 'string' ? description : '' })
}
return entries
}
/**
* `catalog` form: the published entries as a list, read from the source rather
* than re-parsed out of the model-facing prose.
*
* A catalog whose source carries no usable entries falls through to the opaque
* body, so an older or hand-edited log still shows its text.
* @param props - Durable content, its source, and the locale seat.
* @returns The catalog context body.
*/
export function CatalogBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const entries = catalogEntries(source)
if (entries.length === 0) return <OpaqueBody content={content} source={source} t={t} />
return (
<ul className={css.entries} data-context-entries>
{entries.map(entry => (
<li key={entry.name} className={css.entry}>
<code className={css.entryName}>{entry.name}</code>
<span className={css.entryDescription}>{entry.description}</span>
</li>
))}
</ul>
)
}

View File

@@ -45,7 +45,6 @@
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
/* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
font: 400 11px/16px var(--ds-font-family-code);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -1,55 +1,19 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import { CatalogBody, InstructionsBody, OpaqueBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
const MAX_CHARS = 20_000
function inlineJson(payload: unknown): string {
const raw = JSON.stringify(payload)
let formatted = ''
let quoted = false
let escaped = false
for (let index = 0; index < raw.length; index++) {
const char = raw.charAt(index)
if (quoted) {
formatted += char
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === '"') quoted = false
continue
}
if (char === '"') {
quoted = true
formatted += char
continue
}
if (char === '{' || char === '[') {
formatted += char
const close = char === '{' ? '}' : ']'
if (raw[index + 1] !== close) formatted += ' '
continue
}
if (char === '}' || char === ']') {
const open = char === '}' ? '{' : '['
if (raw[index - 1] !== open) formatted += ' '
formatted += char
continue
}
formatted += char === ':' || char === ',' ? `${char} ` : char
}
return formatted
}
/** Props for the logged non-user message presentation. */
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
/** Role and producer name projected from the durable source. */
provenance: ContextMessageNode['provenance']
/** Producer-declared information form; null renders the opaque body. */
form: ContextMessageNode['form']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -60,18 +24,13 @@ export interface ContextInjectionRowProps {
* The header names the role the context plays and, beside it, the producer the
* durable source identifies, so a reader can tell an injected skill catalog
* from a workspace instruction file or a recalled session without expanding.
* @param props - Durable content, its projected provenance, and the locale seat.
* @returns A collapsed context row with a bounded JSON body.
* The expanded body follows the producer-declared form; an absent or unknown
* form renders the opaque body.
* @param props - Durable content, its projected provenance and form, and the locale seat.
* @returns A collapsed context row with a bounded, form-specific body.
*/
export function ContextInjectionRow({ content, source, provenance, t }: ContextInjectionRowProps) {
export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
const text = inlineJson({ content, source })
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}, [content, open, source, t])
return (
<DisclosureRow
@@ -94,7 +53,13 @@ export function ContextInjectionRow({ content, source, provenance, t }: ContextI
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-context-injection-body>{body}</pre>
<div className={css.body} data-context-injection-body data-context-form={form ?? undefined}>
{form === 'instructions'
? <InstructionsBody content={content} source={source} t={t} />
: form === 'catalog'
? <CatalogBody content={content} source={source} t={t} />
: <OpaqueBody content={content} source={source} t={t} />}
</div>
</DisclosureRow>
)
}

View File

@@ -257,6 +257,7 @@ export const MessageItem = memo(function MessageItem({
content={node.content}
source={node.source}
provenance={node.provenance}
form={node.form}
t={t}
/>
)

View File

@@ -55,6 +55,9 @@ export const zh = {
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.context.instructions.loaded': '已载入',
'message.context.instructions.updated': '已更新',
'message.context.instructions.removed': '已移除',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
@@ -170,6 +173,9 @@ export const en = {
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.context.instructions.loaded': 'loaded',
'message.context.instructions.updated': 'updated',
'message.context.instructions.removed': 'removed',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',