Merge branch 'master' into worktree/web-carrier-chain

This commit is contained in:
imccyu
2026-07-23 19:46:13 +08:00
committed by GitHub
16 changed files with 713 additions and 15 deletions

View File

@@ -24,6 +24,28 @@ function text(t: string): ContentBlock[] {
return [{ type: 'text', text: t }]
}
const MARKDOWN_FIXTURE = [
'# Markdown fixture',
'',
'Assistant output renders **strong text**, *emphasis*, and `inline code`.',
'',
'- first item',
' - nested item',
'',
'| Surface | State |',
'| --- | --- |',
'| history | rendered |',
'| streaming | stable |',
'',
'[DeepSeek](https://www.deepseek.com)',
'',
'```ts',
'const markdown = true',
'```',
].join('\n')
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
function sid(id: string): SessionId {
return id as SessionId
}
@@ -40,7 +62,13 @@ function buildAlphaLog(): SessionEvent[] {
}
for (let turn = 0; turn < 60; turn++) {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
push({
type: 'user/message', surfaceOp: 'append',
data: {
content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`),
source: { kind: 'user' },
},
})
if (turn % 9 === 4) {
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
@@ -49,7 +77,7 @@ function buildAlphaLog(): SessionEvent[] {
const withReasoning = turn % 3 === 1
const blocks: ContentBlock[] = []
if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
if (withTool) {
const callId = `fx-call-${turn}`
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
@@ -343,8 +371,8 @@ export function createFixtureApi(): ApiProxy {
const step = 0
append(id, { type: 'step/start', data: { turn, step } })
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
/* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
/* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */
const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText]
let i = 0
const finish = (aborted: boolean): void => {
replays.delete(id)
@@ -410,7 +438,13 @@ export function createFixtureApi(): ApiProxy {
setRunning(id, true)
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
startReply(
id,
turn,
userText === 'render markdown'
? MARKDOWN_FIXTURE
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
)
return ok(request, { accepted: true as const })
},
cancel: (request) => {

View File

@@ -113,7 +113,7 @@ describe('createFixtureApi', () => {
const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
// Real prompt: replay starts (running flips true), cancel freezes it.
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] }))
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
await api.sessions.cancel(req({ sessionId: id }))

View File

@@ -7,7 +7,7 @@
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MessageText key={i} text={block.text} />
case 'text': return <MarkdownText key={i} text={block.text} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null

View File

@@ -158,6 +158,44 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelectorAll('h1')).toHaveLength(1)
const literal = view.getByText((_content, element) => (
element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown
))
expect(literal.querySelector('h1')).toBeNull()
act(() => {
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } })
})
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered')
act(() => {
h.set({
nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)],
partial: null,
})
})
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
expect(view.container.querySelector('[data-streaming="true"]')).toBeNull()
act(() => {
h.set({
nodes: [
user(1, markdown),
assistant(2, markdown),
{ ...assistant(3, markdown), interrupted: true },
],
})
})
expect(view.getByText('已停止')).toBeTruthy()
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
})
it('streaming partial frames re-render only the tail (Profiler count)', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],

View File

@@ -1,6 +1,10 @@
# @deepseek-ai/dsh-client-ui-primitives
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/JsonBlock). Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
## 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.
## Model Experience
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
- **MessageText renders plain text** — markdown support swaps this component's internals later; consumers must not assume block structure.

View File

@@ -21,7 +21,9 @@
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0",
"react": "^18.2.0"
"react": "^18.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",

View File

@@ -15,5 +15,6 @@ export type { MenuItem } from './Menu.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export * from './icons/index.tsx'

View File

@@ -0,0 +1,123 @@
.markdown {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
overflow-wrap: anywhere;
font: var(--dsw-font-markdown-base);
}
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
margin: 0;
}
.markdown h1 {
font: var(--dsw-font-markdown-h1);
}
.markdown h2 {
font: var(--dsw-font-markdown-h2);
}
.markdown h3 {
font: var(--dsw-font-markdown-h3);
}
.markdown :where(h4, h5, h6) {
font: var(--dsw-font-markdown-h4);
}
.markdown :where(strong, th) {
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
}
.markdown :where(ul, ol) {
padding-inline-start: 24px;
}
.markdown li + li {
margin-block-start: 4px;
}
.markdown li > :where(ul, ol) {
margin-block-start: 4px;
}
.markdown blockquote {
padding-inline-start: 12px;
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
color: var(--dsw-alias-label-secondary);
}
.markdown a {
color: var(--dsw-alias-state-business-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown :not(pre) > code {
padding: 2px 4px;
border-radius: 4px;
background: var(--dsw-alias-markdown-inline-code);
font: var(--dsw-font-markdown-code);
}
.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 pre code {
padding: 0;
background: transparent;
font: inherit;
overflow-wrap: normal;
word-break: normal;
white-space: pre;
}
.markdown hr {
width: 100%;
border: 0;
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-state-business-primary);
}
.tableScroll {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.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;
}
.tableScroll th {
background: var(--dsw-alias-markdown-code-block-banner);
font: var(--dsw-font-markdown-table-head);
}
.imageAlt {
color: var(--dsw-alias-label-tertiary);
font-style: italic;
}

View File

@@ -0,0 +1,64 @@
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
return ''
}
}
const safeUrl: UrlTransform = url => sanitizeUrl(url)
const components: Components = {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
}
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection.
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text }: { text: string }) {
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={components}
urlTransform={safeUrl}
>
{text}
</ReactMarkdown>
</div>
)
}

View File

@@ -1,4 +1,4 @@
// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes).
// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
import css from './MessageText.module.css'

View File

@@ -1,14 +1,93 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('MessageText', () => {
it('renders the text verbatim', () => {
const { container } = render(<MessageText text={'line1\nline2'} />)
expect(container.textContent).toBe('line1\nline2')
const { container } = render(<MessageText text={'# line1\n`line2`'} />)
expect(container.textContent).toBe('# line1\n`line2`')
expect(container.querySelector('h1')).toBeNull()
})
})
describe('MarkdownText', () => {
it('renders CommonMark and GFM elements as semantic DOM', () => {
const markdown = [
'# Heading',
'',
'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
'Hard break.',
'',
'> Quote',
'',
'- parent',
' - child',
'',
'1. first',
'2. second',
'',
'- [x] done',
'- [ ] pending',
'',
'| Name | Value |',
'| --- | --- |',
'| alpha | beta |',
'',
'---',
'',
'```ts',
'const answer = 42',
'```',
'',
'<https://deepseek.com>',
].join('\n')
const { container } = render(<MarkdownText text={markdown} />)
expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
expect(container.querySelector('strong')?.textContent).toBe('strong')
expect(container.querySelector('em')?.textContent).toBe('emphasis')
expect(container.querySelector('del')?.textContent).toBe('deleted')
expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
expect(container.querySelectorAll('ul')).toHaveLength(3)
expect(container.querySelector('ol')).not.toBeNull()
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
expect(container.querySelector('hr')).not.toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
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()
})
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',
'<img src="x" onerror="globalThis.compromised = true">',
'[script](javascript:alert(1)) [relative](/settings)',
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
'![remote diagram](https://example.com/private.png)',
].join('\n\n')
const { container } = render(<MarkdownText text={markdown} />)
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('img')).toBeNull()
const neutralized = [...container.querySelectorAll('p')]
.find(paragraph => paragraph.textContent === 'script relative')
expect(neutralized?.querySelector('a')).toBeNull()
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
expect(screen.getByText('remote diagram')).toBeTruthy()
})
it('keeps incomplete streaming Markdown renderable', () => {
const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(screen.getByText('**unfinished')).toBeTruthy()
})
})