Merge remote-tracking branch 'origin/feat/search-presenter' into feat/web-search-card

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
#	packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/src/index.ts
This commit is contained in:
Chinesezjc
2026-07-31 12:27:30 +08:00
948 changed files with 28959 additions and 4629 deletions

View File

@@ -1,5 +1,5 @@
// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale +
// "deepseek" letterforms + HARNESS badge plate in one svg. Native 182x24.
// "deepseek-official" letterforms + HARNESS badge plate in one svg. Native 182x24.
// Ink rides currentColor; the badge text is knocked out in the inverted
// label color so the plate stays legible in both themes.

View File

@@ -8,9 +8,14 @@ import css from './ConnectionBanner.module.css'
/**
* Render the reconnecting banner.
* @param props.reconnecting - true while the connection is in backoff/retry.
* @param props.label - banner text; the owner passes localized copy (this
* package is cordis-free, so copy arrives via props).
* @returns the banner, or null when connected.
*/
export function ConnectionBanner({ reconnecting }: { reconnecting: boolean }) {
export function ConnectionBanner({ reconnecting, label = '连接已断开,正在重连…' }: {
reconnecting: boolean
label?: string | undefined
}) {
if (!reconnecting) return null
return <div className={css.banner}></div>
return <div className={css.banner}>{label}</div>
}

View File

@@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useEffect, useId, useRef, useState } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
@@ -14,16 +14,64 @@ import css from './JsonTree.module.css'
const OBJECT_PREVIEW_LIMIT = 4
const ARRAY_PREVIEW_LIMIT = 5
const PREVIEW_DEPTH_LIMIT = 2
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'value', label: 'Copy value' },
{ id: 'json', label: 'Copy JSON' },
{ id: 'path', label: 'Copy property path' },
]
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'prettyJson', label: 'Copy pretty JSON' },
{ id: 'json', label: 'Copy compact JSON' },
{ id: 'path', label: 'Copy property path' },
]
/**
* Display copy for the tree's copy affordance; the owner passes localized
* labels (this package is cordis-free, so copy arrives via props). Every
* field defaults to the current built-in value, so existing consumers render
* unchanged.
*/
export interface JsonTreeLabels {
/** Menu item: copy the raw primitive value. */
copyValue: string
/** Menu item: copy the value as compact JSON (primitive rows). */
copyJson: string
/** Menu item: copy the property path. */
copyPath: string
/** Menu item: copy the value as pretty-printed JSON. */
copyPrettyJson: string
/** Menu item: copy the value as compact JSON (object rows). */
copyCompactJson: string
/** Copy-button state label after a successful copy. */
copied: string
/** Copy-button state label after a failed copy. */
copyFailed: string
/** Expander aria label while expanded. */
collapseNode: string
/** Expander aria label while collapsed. */
expandNode: string
/** Copy-button tooltip, given the current action label. */
copyButtonTitle: (action: string) => string
}
const DEFAULT_LABELS: JsonTreeLabels = {
copyValue: 'Copy value',
copyJson: 'Copy JSON',
copyPath: 'Copy property path',
copyPrettyJson: 'Copy pretty JSON',
copyCompactJson: 'Copy compact JSON',
copied: 'Copied',
copyFailed: 'Copy failed',
collapseNode: 'Collapse JSON node',
expandNode: 'Expand JSON node',
copyButtonTitle: action => `${action}; right-click for copy options`,
}
function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
return [
{ id: 'value', label: labels.copyValue },
{ id: 'json', label: labels.copyJson },
{ id: 'path', label: labels.copyPath },
]
}
function objectCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
return [
{ id: 'prettyJson', label: labels.copyPrettyJson },
{ id: 'json', label: labels.copyCompactJson },
{ id: 'path', label: labels.copyPath },
]
}
type JsonPath = readonly (number | string)[]
@@ -193,6 +241,7 @@ function NodeField({
interface JsonTreeNodeProps {
field?: string
initialExpanded: boolean
labels: JsonTreeLabels
lastElement: boolean
onClaimTabStop: (id: string) => void
onRowHover: (row: HTMLElement, target: RowTarget) => void
@@ -204,6 +253,7 @@ interface JsonTreeNodeProps {
function JsonTreeNode({
field,
initialExpanded,
labels,
lastElement,
onClaimTabStop,
onRowHover,
@@ -279,7 +329,7 @@ function JsonTreeNode({
className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
data-json-expander
role="button"
aria-label={expanded ? 'Collapse JSON node' : 'Expand JSON node'}
aria-label={expanded ? labels.collapseNode : labels.expandNode}
aria-expanded={expanded}
aria-controls={expanded ? contentsId : undefined}
tabIndex={tabStopId === nodeId ? 0 : -1}
@@ -298,6 +348,7 @@ function JsonTreeNode({
field={key}
value={item}
path={[...path, Array.isArray(value) ? index : key]}
labels={labels}
lastElement={index === entries.length - 1}
initialExpanded={false}
tabStopId={tabStopId}
@@ -344,6 +395,8 @@ export interface JsonTreeProps {
copyable?: boolean
/** Whether the top-level object or array is always expanded. */
expandTopLevel?: boolean
/** Localized display copy; omitted fields keep the built-in defaults. */
labels?: Partial<JsonTreeLabels> | undefined
}
/**
@@ -357,7 +410,12 @@ export function JsonTree({
className,
copyable = true,
expandTopLevel = true,
labels,
}: JsonTreeProps) {
const copyLabels = useMemo<JsonTreeLabels>(
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
[labels],
)
const rootEntries = entriesOf(data)
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
isExpandableValue(value) && entriesOf(value).length > 0
@@ -486,10 +544,10 @@ export function JsonTree({
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
const copyTitle = copyState === 'copied'
? 'Copied'
? copyLabels.copied
: copyState === 'failed'
? 'Copy failed'
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
? copyLabels.copyFailed
: copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue
return (
<div
@@ -525,6 +583,7 @@ export function JsonTree({
field={key}
value={value}
path={[Array.isArray(data) ? index : key]}
labels={copyLabels}
lastElement={index === rootEntries.length - 1}
initialExpanded={false}
tabStopId={tabStopId}
@@ -543,6 +602,7 @@ export function JsonTree({
<JsonTreeNode
value={data}
path={[]}
labels={copyLabels}
lastElement
initialExpanded
tabStopId={tabStopId}
@@ -570,7 +630,7 @@ export function JsonTree({
data-json-copy-button
data-state={copyState}
aria-label={copyTitle}
title={`${copyTitle}; right-click for copy options`}
title={copyLabels.copyButtonTitle(copyTitle)}
onClick={() => void copy(defaultCopyMode)}
onContextMenu={(event) => {
event.preventDefault()
@@ -584,7 +644,7 @@ export function JsonTree({
: <IconCopyOutline16 size={12} />}
</button>
)}
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)}
onSelect={(id) => {
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
copyMenuOpenRef.current = false

View File

@@ -13,18 +13,24 @@ import css from './Modal.module.css'
* @param props.open - whether the dialog is showing.
* @param props.onClose - Escape or mask click.
* @param props.title - dialog heading (aria-label in every mode).
* @param props.closeLabel - accessible close-button label.
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
* @param props.headless - render children directly in the card (no default
* header/close/body chrome) for dialogs whose figma frame owns its own
* header structure; mask, card, Escape, and aria-label remain.
* @param props.closeLabel - close-button aria label; the owner passes
* localized copy (this package is cordis-free, so copy arrives via props).
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
export function Modal({
open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false,
}: {
open: boolean
onClose: () => void
title: string
closeLabel?: string
description?: string
children?: ReactNode
footer?: ReactNode
@@ -58,7 +64,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>

View File

@@ -7,6 +7,10 @@
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
/* Rebindable by consumers (CodeBlock's --dsl-code-block-content-font
pattern): a surface wanting the smaller code size rebinds this together
with --dsl-terminal-line-height on its own container. */
--dsl-terminal-font: var(--dsw-font-markdown-code-block);
/* The card's own left inset, holding the run-state dot in a column of its own
so it never competes with the commands for horizontal space. */
--dsl-terminal-gutter: 30px;
@@ -22,26 +26,49 @@
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
/* Clip the banner to the card's own radius: when a consumer adds a border,
the banner's equal corner radius no longer nests inside it and leaves a
notch at the corner. Nothing inside renders out of the box. */
overflow: hidden;
}
/* Top-aligned: the status pill and copy control stay on the first prompt row
however many command lines the card carries. */
/* The status pill and copy control top-align to the FIRST prompt row (their
heights are capped to the prompt line, so on a multi-line command they sit
with the first command instead of floating mid-banner). */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
/* Pulled back across the card's gutter padding so the banner background and
its top-left radius span the FULL surface, then re-inset by the same amount
so the prompt text and the dot keep their positions. A plain block child
only reaches the content box, which left the gutter column painted in the
body color and drew the card's top-left corner in it — invisible in the
light theme, where banner and body share a token, and visible in the dark
one, where they do not. */
/* Pulled back across the card's gutter padding so the banner spans the FULL
surface, then re-inset by the same amount so the prompt text and the dot
keep their positions. The banner shares the card's own surface (no banner
token): the l2 divider below is the section boundary. */
margin-left: calc(-1 * var(--dsl-terminal-gutter));
padding: 9px 14px 9px var(--dsl-terminal-gutter);
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
border-top-right-radius: var(--dsl-terminal-radius);
/* A long multi-line command scrolls inside the banner (same cap as the
IN/OUT card's sections) instead of pushing the output off screen. */
max-height: 150px;
overflow-y: auto;
}
/* Banner scrollbar floats off the card edge like the output's. */
.header::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
.header::-webkit-scrollbar-track {
margin: 6px;
}
/* Full-width l2 hairline between the command banner and the body — the same
divider the IN/OUT card draws between its sections. A running card is
banner-only, so it draws none. */
.block:not([data-running]) .header {
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* One row per command line. The prompt column is the only element allowed to
@@ -51,7 +78,7 @@
flex-direction: column;
min-width: 0;
flex: 1;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
}
.promptLine {
@@ -100,27 +127,59 @@
white-space: pre;
}
/* Capped to the prompt's line height (Pill's own 24px height would exceed a
smaller-font prompt row and stretch the banner). Sticky against the
banner's own scroll so the pill and the copy control stay in reach while a
long command scrolls underneath. */
.status {
flex: none;
position: sticky;
top: 0;
height: var(--dsl-terminal-line-height);
color: var(--dsw-alias-state-error-primary);
}
.copyButton {
flex: none;
background-color: transparent;
position: sticky;
top: 0;
/* Card surface, not transparent: the control is sticky over the banner's
own scroll, so scrolled command text must not bleed through it. */
background-color: var(--dsw-alias-markdown-code-block);
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
line-height: var(--dsl-terminal-line-height);
}
/* Vertical scrolling lives on the OUTPUT, not the card root: a root scroller
would run its scrollbar over the banner (and the copy control), while here
the banner stays pinned and the bar sits inside the output's right padding.
Unset, the max-height is none and the auto overflow never engages. */
.output {
max-height: var(--dsl-terminal-output-max-height, none);
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
overflow-x: auto;
overflow-y: hidden;
overflow-y: auto;
}
/* Both output scrollbars (vertical cap, horizontal pre overflow) float 2px
off the card edge: a transparent border clips the thumb inward so it never
hugs the rounded corner. */
.output::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
/* Track end-margins keep the thumb's travel out of the card's rounded
corners in both directions. */
.output::-webkit-scrollbar-track {
margin: 6px;
}
/* No wrapping, no word-break: alignment is the payload of terminal output. */
@@ -147,6 +206,6 @@
.empty {
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -21,6 +21,54 @@ import css from './TerminalBlock.module.css'
*/
export const DEFAULT_TERMINAL_MAX_LINES = 16
/**
* Display copy for the terminal surface; the owner passes localized labels
* (this package is cordis-free, so copy arrives via props). Every field
* defaults to the current built-in value, so existing consumers render
* unchanged.
*/
export interface TerminalBlockLabels {
/** Status pill text for a signal-terminated command. */
signal: (signal: string) => string
/** Status pill text for a non-zero exit code. */
exitCode: (exitCode: number) => string
/** Run-state text while the command is still running. */
running: string
/** Run-state text for a signal or non-zero-exit settle. */
failed: string
/** Run-state text for a clean settle. */
done: string
/** Copy-button idle label. */
copy: string
/** Copy-button label during the post-copy confirmation window. */
copied: string
/** Placeholder when a settled command produced no visible output. */
noOutput: string
/** Collapse-toggle aria label while expanded. */
collapseAria: string
/** Collapse-toggle text while expanded. */
collapse: string
/** Expand-toggle aria label while capped, given the hidden line count. */
expandAria: (hidden: number) => string
/** Expand-toggle text while capped, given the hidden line count. */
expand: (hidden: number) => string
}
const DEFAULT_LABELS: TerminalBlockLabels = {
signal: signal => `信号 ${signal}`,
exitCode: exitCode => `退出码 ${exitCode}`,
running: '运行中',
failed: '失败',
done: '已完成',
copy: '复制',
copied: '复制成功',
noOutput: '无输出',
collapseAria: '收起输出',
collapse: '收起',
expandAria: hidden => `展开其余 ${hidden} 行输出`,
expand: hidden => `… 其余 ${hidden}`,
}
export interface TerminalBlockProps {
/** The command line, rendered verbatim after the prompt label. */
command: string
@@ -36,10 +84,12 @@ export interface TerminalBlockProps {
signal?: string | undefined
/** The command is still running: the block shows the prompt line alone. */
running?: boolean | undefined
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Localized display copy; omitted fields keep the built-in defaults. */
labels?: Partial<TerminalBlockLabels> | undefined
}
/**
@@ -64,11 +114,16 @@ function promptLabel(cwd: string, home: string | undefined): string {
* distinction the bash tool's own exit-status markers draw.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @param labels - display copy for the pill text.
* @returns the pill text, or undefined for a clean exit.
*/
function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
if (signal !== undefined) return `信号 ${signal}`
if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
function statusText(
exitCode: number | undefined,
signal: string | undefined,
labels: TerminalBlockLabels,
): string | undefined {
if (signal !== undefined) return labels.signal(signal)
if (exitCode !== undefined && exitCode !== 0) return labels.exitCode(exitCode)
return undefined
}
@@ -85,16 +140,18 @@ function statusText(exitCode: number | undefined, signal: string | undefined): s
* @param running - the command has not settled.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @param labels - display copy for the text label.
* @returns the dot's state and its text label, since the dot is aria-hidden.
*/
function runState(
running: boolean,
exitCode: number | undefined,
signal: string | undefined,
labels: TerminalBlockLabels,
): { state: StateDotState; label: string } {
if (running) return { state: 'ongoing', label: '运行中' }
if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
return { state: 'done', label: '已完成' }
if (running) return { state: 'ongoing', label: labels.running }
if (statusText(exitCode, signal, labels) !== undefined) return { state: 'error', label: labels.failed }
return { state: 'done', label: labels.done }
}
/**
@@ -124,7 +181,12 @@ export function TerminalBlock({
running = false,
maxLines = DEFAULT_TERMINAL_MAX_LINES,
className,
labels,
}: TerminalBlockProps) {
const copy = useMemo<TerminalBlockLabels>(
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
[labels],
)
const text = output ?? ''
// A command's output ends with a newline; that terminator is not an extra
// blank line to draw or to count against the height cap. The check runs on the
@@ -147,8 +209,8 @@ export function TerminalBlock({
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const status = statusText(exitCode, signal)
const state = runState(running, exitCode, signal)
const status = statusText(exitCode, signal, copy)
const state = runState(running, exitCode, signal, copy)
// A multi-line command gets one prompt row per line, so a two-command shell
// snippet reads as the two commands it is instead of collapsing into one
// ellipsized row. A trailing newline is a terminator, not an empty command.
@@ -192,12 +254,12 @@ export function TerminalBlock({
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
{!running && !empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
{copied ? copy.copied : copy.copy}
</button>
)}
</div>
{!running && (empty
? <div className={css.empty}></div>
? <div className={css.empty}>{copy.noOutput}</div>
: (
<div className={css.output}>
{(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
@@ -208,10 +270,10 @@ export function TerminalBlock({
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
aria-label={expanded ? copy.collapseAria : copy.expandAria(hidden)}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
{expanded ? copy.collapse : copy.expand(hidden)}
</button>
)}
{capped && lines.slice(lines.length - tailLines).map((line, index) => (

View File

@@ -19,16 +19,18 @@ export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps } from './TerminalBlock.tsx'
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
export type {
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
} from './SearchBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'

View File

@@ -17,9 +17,13 @@ export interface CodeBlockProps {
lang?: string | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
const rootRef = useRef<HTMLDivElement>(null)
@@ -55,7 +59,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
{copied ? copiedLabel : copyLabel}
</button>
</div>
</div>

View File

@@ -5,10 +5,17 @@ import css from './JsonBlock.module.css'
const MAX_CHARS = 20_000
export function JsonBlock({ label, payload, defaultOpen = false }: {
/** Default truncation footer; the owner passes a localized formatter. */
function defaultTruncatedLabel(total: number): string {
return `… 已截断,共 ${total} 字符`
}
export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel = defaultTruncatedLabel }: {
label: string
payload: unknown
defaultOpen?: boolean
/** Footer appended when the body exceeds the char cap, given the full length (this package is cordis-free, so copy arrives via props). */
truncatedLabel?: ((total: number) => string) | undefined
}) {
const [open, setOpen] = useState(defaultOpen)
const body = useMemo(() => {
@@ -21,8 +28,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
} catch {
s = String(payload)
}
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n… 已截断,共 ${s.length} 字符` : s
}, [open, payload])
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n${truncatedLabel(s.length)}` : s
}, [open, payload, truncatedLabel])
return (
<div className={css.root}>
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>

View File

@@ -1,4 +1,4 @@
import { isValidElement } from 'react'
import { isValidElement, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -24,8 +24,16 @@ function sanitizeUrl(url: string): string {
const safeUrl: UrlTransform = url => sanitizeUrl(url)
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean): Components {
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
return {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
@@ -62,7 +70,14 @@ function buildComponents(streaming: boolean): Components {
// keeps the stock <pre> rather than guessing.
if (typeof raw !== 'string') return <pre>{children}</pre>
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
return <CodeBlock code={raw} lang={streaming ? undefined : lang} />
return (
<CodeBlock
code={raw}
lang={streaming ? undefined : lang}
copyLabel={codeLabels?.copyLabel}
copiedLabel={codeLabels?.copiedLabel}
/>
)
},
}
}
@@ -73,15 +88,29 @@ const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences plain (highlighting lands on the finalize swap).
* `streaming` renders fences plain (highlighting lands on the finalize swap);
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
export function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
streaming?: boolean
codeLabels?: MarkdownCodeLabels | undefined
}) {
// The label-free tables stay module-level singletons so the common case
// keeps referential stability across renders without a hook.
const components = useMemo(() => {
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
return buildComponents(streaming, codeLabels)
}, [streaming, codeLabels])
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={streaming ? streamingComponents : staticComponents}
components={components}
urlTransform={safeUrl}
>
{text}