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:
@@ -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;
|
||||
}
|
||||
227
packages/client/ui-conversation/src/client/chat/ContextBody.tsx
Normal file
227
packages/client/ui-conversation/src/client/chat/ContextBody.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ export const MessageItem = memo(function MessageItem({
|
||||
content={node.content}
|
||||
source={node.source}
|
||||
provenance={node.provenance}
|
||||
form={node.form}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user