|
|
@@ -5,7 +5,7 @@
|
|
|
// 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 type { ContextMessageNode, KnownContextForm } 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'
|
|
|
@@ -27,6 +27,9 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
* 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.
|
|
|
+ *
|
|
|
+ * Blocks join with no separator, matching how provider adapters flatten them:
|
|
|
+ * inserting a line break here would show the reader a line the model never saw.
|
|
|
*/
|
|
|
function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } {
|
|
|
const texts: string[] = []
|
|
|
@@ -35,7 +38,7 @@ function partitionContent(content: ContextMessageNode['content']): { text: strin
|
|
|
if (block.type === 'text') texts.push(block.text)
|
|
|
else rest.push(block)
|
|
|
}
|
|
|
- return { text: texts.join('\n'), rest }
|
|
|
+ return { text: texts.join(''), rest }
|
|
|
}
|
|
|
|
|
|
/** The model-facing text, truncated to the display bound. */
|
|
|
@@ -45,11 +48,16 @@ function boundedText(text: string, t: Translate): string {
|
|
|
: 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)
|
|
|
+/**
|
|
|
+ * One source field rendered as a value row; nested shapes stay compact JSON.
|
|
|
+ * Bounded on its own, because provenance is as unbounded as the text: an unknown
|
|
|
+ * producer may record an arbitrarily large string or array.
|
|
|
+ */
|
|
|
+function fieldValue(value: unknown, t: Translate): string {
|
|
|
+ const text = typeof value === 'string'
|
|
|
+ ? value
|
|
|
+ : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value)
|
|
|
+ return boundedText(text, t)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -57,7 +65,7 @@ function fieldValue(value: unknown): string {
|
|
|
* header already names the producer, and `form` because the presentation the
|
|
|
* reader is looking at IS that value.
|
|
|
*/
|
|
|
-function SourceFields({ source }: { source: unknown }): ReactNode {
|
|
|
+function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNode {
|
|
|
const record = asRecord(source)
|
|
|
if (record === null) return null
|
|
|
const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form')
|
|
|
@@ -67,7 +75,7 @@ function SourceFields({ source }: { source: unknown }): ReactNode {
|
|
|
{rows.map(([key, value]) => (
|
|
|
<div key={key} className={css.field}>
|
|
|
<dt className={css.fieldKey}>{key}</dt>
|
|
|
- <dd className={css.fieldValue}>{fieldValue(value)}</dd>
|
|
|
+ <dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
|
|
|
</div>
|
|
|
))}
|
|
|
</dl>
|
|
|
@@ -98,7 +106,7 @@ export function OpaqueBody({ content, source, t }: {
|
|
|
truncatedLabel={total => t('json.truncated', { total })}
|
|
|
/>
|
|
|
))}
|
|
|
- <SourceFields source={source} />
|
|
|
+ <SourceFields source={source} t={t} />
|
|
|
</>
|
|
|
)
|
|
|
}
|
|
|
@@ -110,26 +118,37 @@ interface InstructionChange {
|
|
|
digest?: string
|
|
|
}
|
|
|
|
|
|
-/** Instruction changes read off the source; empty when the shape is unusable. */
|
|
|
-function instructionChanges(source: unknown): InstructionChange[] {
|
|
|
+/**
|
|
|
+ * Instruction changes read off the source, or null when the record is not a
|
|
|
+ * usable instruction list.
|
|
|
+ *
|
|
|
+ * The read is all-or-nothing: silently dropping one unreadable entry would show
|
|
|
+ * a confident, incomplete file list for a log this version cannot fully read.
|
|
|
+ * Paths are deduplicated in first-seen order, matching how the header label is
|
|
|
+ * derived from the same array.
|
|
|
+ */
|
|
|
+function instructionChanges(source: unknown): InstructionChange[] | null {
|
|
|
const record = asRecord(source)
|
|
|
const list = record === null ? undefined : record['changes']
|
|
|
- if (!Array.isArray(list)) return []
|
|
|
+ if (!Array.isArray(list)) return null
|
|
|
const changes: InstructionChange[] = []
|
|
|
- for (const entry of list) {
|
|
|
+ const seen = new Set<string>()
|
|
|
+ for (const entry of list as readonly unknown[]) {
|
|
|
const change = asRecord(entry)
|
|
|
- if (change === null) continue
|
|
|
+ if (change === null) return null
|
|
|
const path = change['path']
|
|
|
- if (typeof path !== 'string' || path === '') continue
|
|
|
+ if (typeof path !== 'string' || path === '') return null
|
|
|
const action = change['action']
|
|
|
const digest = change['digest']
|
|
|
+ if (seen.has(path)) continue
|
|
|
+ seen.add(path)
|
|
|
changes.push({
|
|
|
action: typeof action === 'string' ? action : '',
|
|
|
path,
|
|
|
...typeof digest === 'string' ? { digest } : {},
|
|
|
})
|
|
|
}
|
|
|
- return changes
|
|
|
+ return changes.length === 0 ? null : changes
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -138,7 +157,8 @@ function instructionChanges(source: unknown): InstructionChange[] {
|
|
|
* 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.
|
|
|
+ * @returns The instructions context body, or the opaque body when the change
|
|
|
+ * list is unreadable.
|
|
|
*/
|
|
|
export function InstructionsBody({ content, source, t }: {
|
|
|
content: ContextMessageNode['content']
|
|
|
@@ -146,22 +166,21 @@ export function InstructionsBody({ content, source, t }: {
|
|
|
t: Translate
|
|
|
}): ReactNode {
|
|
|
const changes = instructionChanges(source)
|
|
|
+ if (changes === null) return <OpaqueBody content={content} source={source} t={t} />
|
|
|
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>
|
|
|
- )}
|
|
|
+ <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
|
|
|
@@ -181,21 +200,26 @@ interface CatalogEntry {
|
|
|
description: string
|
|
|
}
|
|
|
|
|
|
-/** Catalog entries read off the source; empty when the shape is unusable. */
|
|
|
-function catalogEntries(source: unknown): CatalogEntry[] {
|
|
|
+/**
|
|
|
+ * Catalog entries read off the source, or null when the record is not a usable
|
|
|
+ * catalog. All-or-nothing for the same reason as the instruction list: this body
|
|
|
+ * replaces the model-facing text, so a partial list would hide the only complete
|
|
|
+ * account of what the model read.
|
|
|
+ */
|
|
|
+function catalogEntries(source: unknown): CatalogEntry[] | null {
|
|
|
const record = asRecord(source)
|
|
|
const list = record === null ? undefined : record['entries']
|
|
|
- if (!Array.isArray(list)) return []
|
|
|
+ if (!Array.isArray(list)) return null
|
|
|
const entries: CatalogEntry[] = []
|
|
|
- for (const item of list) {
|
|
|
+ for (const item of list as readonly unknown[]) {
|
|
|
const entry = asRecord(item)
|
|
|
- if (entry === null) continue
|
|
|
+ if (entry === null) return null
|
|
|
const name = entry['name']
|
|
|
- if (typeof name !== 'string' || name === '') continue
|
|
|
const description = entry['description']
|
|
|
- entries.push({ name, description: typeof description === 'string' ? description : '' })
|
|
|
+ if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
|
|
|
+ entries.push({ name, description })
|
|
|
}
|
|
|
- return entries
|
|
|
+ return entries.length === 0 ? null : entries
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -205,7 +229,8 @@ function catalogEntries(source: unknown): CatalogEntry[] {
|
|
|
* 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.
|
|
|
+ * @returns The catalog context body, or the opaque body when the entry list is
|
|
|
+ * unreadable.
|
|
|
*/
|
|
|
export function CatalogBody({ content, source, t }: {
|
|
|
content: ContextMessageNode['content']
|
|
|
@@ -213,15 +238,55 @@ export function CatalogBody({ content, source, t }: {
|
|
|
t: Translate
|
|
|
}): ReactNode {
|
|
|
const entries = catalogEntries(source)
|
|
|
- if (entries.length === 0) return <OpaqueBody content={content} source={source} t={t} />
|
|
|
+ if (entries === null) return <OpaqueBody content={content} source={source} t={t} />
|
|
|
+ const update = asRecord(source)?.['update'] === true
|
|
|
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>
|
|
|
+ <>
|
|
|
+ {update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
|
|
|
+ <ul className={css.entries} data-context-entries>
|
|
|
+ {entries.map((entry, index) => (
|
|
|
+ // Index key: a hand-edited or foreign log may repeat a name, and a
|
|
|
+ // duplicate React key would drop a row the model did see.
|
|
|
+ <li key={index} className={css.entry}>
|
|
|
+ <code className={css.entryName}>{entry.name}</code>
|
|
|
+ <span className={css.entryDescription}>{entry.description}</span>
|
|
|
+ </li>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ </>
|
|
|
)
|
|
|
}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Choose the body for one context node.
|
|
|
+ *
|
|
|
+ * Returns the form the body actually rendered as, which is not always the
|
|
|
+ * declared one: a declared form whose fields are unreadable falls back to
|
|
|
+ * opaque, and the caller labels the row with what it really shows.
|
|
|
+ * @param form - the producer-declared form projected onto the node.
|
|
|
+ * @param props - durable content, its source, and the locale seat.
|
|
|
+ * @returns the rendered form (null for opaque) and its body.
|
|
|
+ */
|
|
|
+export function contextBody(
|
|
|
+ form: ContextMessageNode['form'],
|
|
|
+ props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
|
|
|
+): { rendered: KnownContextForm | null; body: ReactNode } {
|
|
|
+ switch (form) {
|
|
|
+ case 'instructions':
|
|
|
+ return instructionChanges(props.source) === null
|
|
|
+ ? { rendered: null, body: <OpaqueBody {...props} /> }
|
|
|
+ : { rendered: 'instructions', body: <InstructionsBody {...props} /> }
|
|
|
+ case 'catalog':
|
|
|
+ return catalogEntries(props.source) === null
|
|
|
+ ? { rendered: null, body: <OpaqueBody {...props} /> }
|
|
|
+ : { rendered: 'catalog', body: <CatalogBody {...props} /> }
|
|
|
+ case null:
|
|
|
+ return { rendered: null, body: <OpaqueBody {...props} /> }
|
|
|
+ /* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
|
|
|
+ KnownContextForm here rather than letting it degrade to opaque silently. */
|
|
|
+ default: {
|
|
|
+ const unreachable: never = form
|
|
|
+ throw new Error(`unreachable context form: ${String(unreachable)}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|