feat(web-shell): support compact echarts full data blocks (#6232)

* feat(web-shell): support custom code block rendering

* fix(web-shell): harden custom code block rendering

* docs: add skill capability gating design

* fix(web-shell): make chart skill host supplied

* docs(web-shell): write chart skill in English

* docs(web-shell): document full-data chart payload

* docs(web-shell): use dataset-backed chart payload

* feat(web-shell): add echarts full-data renderer

* chore(web-shell): keep chart skill host supplied

* fix(web-shell): show loading for streaming chart blocks

* style(web-shell): polish echarts full-data renderer

* fix(web-shell): harden custom code block language parsing

* fix(web-shell): harden echarts full-data renderer

* fix(web-shell): polish echarts renderer followups

* fix(web-shell): reuse enhanced table for chart data

* fix(web-shell): recover chart renderer after errors

* fix(web-shell): harden chart option handling

* fix(web-shell): harden chart data rendering

* fix(web-shell): tighten chart renderer guardrails

* fix(web-shell): update chart fallback title

* fix(web-shell): polish chart renderer review fixes

* feat(web-shell): support compact echarts full data blocks

* docs(web-shell): add chart skill template

* fix(web-shell): address chart review suggestions

* fix(web-shell): preserve punctuation language aliases

* fix(web-shell): address chart follow-up review

* fix(web-shell): harden chart ref resolution

* fix(web-shell): cover chart sanitizer follow-ups

* fix(web-shell): address chart review follow-ups

* fix(web-shell): address chart review leftovers

* fix(web-shell): close chart review gaps

* fix(web-shell): handle latest chart review
This commit is contained in:
zhangxy-zju 2026-07-04 23:36:54 +08:00 committed by GitHub
parent 90e1e3d47e
commit e9a7917d5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 6673 additions and 95 deletions

View file

@ -0,0 +1,342 @@
# Skill Required Capabilities Design
Status: design note; this PR proceeds with Option B and leaves
`required-capabilities` as a future proposal.
## Context
Web Shell can render custom fenced code blocks through its markdown renderer. The
chart renderer proposal uses an `echarts-fulldata` fenced code block so the model
can return a complete ECharts option and dataset payload that Web Shell renders
as an interactive chart.
That output contract is only useful in clients that can render it. In the CLI,
ACP clients, or any other surface without a matching renderer, the same response
would appear as a large code block instead of a chart.
The initial bundled chart skill proposal relied on wording to tell the model
that the format is for Web Shell. This is a soft guard. If the skill is exposed
in a non-Web-Shell session, the model can still choose an output format that the
client cannot render.
For the current PR, Qwen Code keeps the renderer extension point in Web Shell
but does not bundle `qwencode-viz` in core. The Web Shell package includes a
copyable, non-auto-loaded skill template, and hosts should install or inject
that skill only when they also register an `echarts-fulldata` renderer.
## Problem
Qwen Code needs a clear way to decide whether a host-specific skill should be
shown to the model and to users.
For `qwencode-viz`, the concrete question is:
- Should core support a generic `required-capabilities` skill metadata field?
- Or should `qwencode-viz` not be a core bundled skill at all, and instead be
supplied only by Web Shell clients that install or inject it?
## Goals
- Prevent renderer-specific skills from being exposed when the current client
cannot satisfy their output contract.
- Keep startup skill reminders, explicit skill activation, slash-command
discovery, and skill validation consistent.
- Avoid hardcoding `qwencode-viz` as a special case.
- Preserve existing skill behavior when no capability requirement is declared.
- Keep the design extensible for future host capabilities, not only ECharts.
## Non-goals
- Implementing the ECharts renderer itself.
- Redesigning all client/server capability negotiation.
- Changing the semantics of existing skill frontmatter.
- Solving multi-client shared-session capability changes in the first version.
## Current Related Mechanisms
The codebase already has several visibility controls, but none represent client
rendering capabilities:
- `disable-model-invocation`: prevents a skill from being auto-invoked by the
model.
- `user-invocable`: controls whether a bundled skill is available as a command.
- `paths`: scopes skill availability to matching workspace paths.
- `skills.disabled`: disables configured skills.
- `allowedTools`: currently used by bundled skill loading to hide cron-oriented
skills when cron tools are unavailable.
- Slash command `supportedModes`: filters commands by execution mode.
- Daemon and ACP capability objects: describe protocol or client support, but
are not currently connected to skill exposure.
There is no existing `required-capabilities` or equivalent skill frontmatter.
Adding it would be a new skill contract.
## Option A: Add `required-capabilities`
Add a generic skill frontmatter field:
```yaml
---
name: qwencode-viz
description: Render analytical charts in Web Shell using echarts-fulldata fenced code blocks.
required-capabilities:
- markdown.codeBlock.echarts-fulldata
---
```
When the current client/session does not advertise all listed capabilities, the
skill is treated as unavailable.
### Capability Naming
Use namespaced string capabilities:
```text
markdown.codeBlock.echarts-fulldata
```
This keeps the field generic while making the contract precise:
- `markdown`: the capability belongs to rendered markdown.
- `codeBlock`: the capability applies to fenced code block rendering.
- `echarts-fulldata`: the specific language/info string supported by the
renderer.
Future examples could be:
- `markdown.codeBlock.vega-lite`
- `markdown.codeBlock.mermaid-interactive`
- `artifact.openUrl`
### Skill Metadata
Add `requiredCapabilities?: string[]` to skill configuration after parsing the
frontmatter key `required-capabilities`.
Both skill parsing paths should understand the field:
- `packages/core/src/skills/skill-load.ts`
- `packages/core/src/skills/skill-manager.ts`
The field should be optional. Missing or empty means the skill has no client
capability requirement.
### Runtime Capability Source
Add client/session capabilities to the runtime config:
```ts
interface ConfigParameters {
clientCapabilitiesProvider?: () => ReadonlySet<string>;
}
```
Expose a helper on `Config`, for example:
```ts
config.getClientCapabilities(): ReadonlySet<string>
```
Then centralize the check:
```ts
function skillMeetsRequiredCapabilities(skill: Skill, config: Config): boolean {
return skill.config.requiredCapabilities.every((capability) =>
config.getClientCapabilities().has(capability),
);
}
```
### Filtering Points
The capability filter should be applied before skills are exposed to either the
model or the user:
- `collectAvailableSkillEntries` in `packages/core/src/tools/skill-utils.ts`
should skip skills whose required capabilities are missing. This keeps startup
skill reminders, delta reminders, `SkillTool` validation, and model-invocable
activation aligned.
- `BundledSkillLoader` should skip unavailable bundled skills when creating
user-facing commands.
- `SkillCommandLoader` should skip unavailable file-system skills when creating
user-facing commands.
The important invariant is that a skill hidden from the model should not still
appear as an invocable command unless the project intentionally supports a
manual override.
### Web Shell Registration
Web Shell should advertise renderer support explicitly rather than relying on
the presence of an opaque `renderCodeBlock` callback.
For example:
```tsx
<WebShell
customization={{
markdown: {
renderableCodeBlockLanguages: ['echarts-fulldata'],
renderCodeBlock(info) {
// render custom blocks
},
},
}}
/>
```
The Web Shell client can map that to:
```text
markdown.codeBlock.echarts-fulldata
```
This makes the capability declaration stable even if the renderer callback
contains custom logic, fallbacks, or multiple supported languages.
### Daemon and ACP Propagation
For hosted or daemon-based sessions, the client capability set needs to reach
core before skills are loaded or listed. A minimal version can pass capabilities
when creating a session:
```ts
interface CreateSessionRequest {
clientCapabilities?: string[];
}
```
The daemon bridge, SDK, and ACP session creation flow can store this as
session-scoped config.
For the first version, capabilities can be session-scoped. If multiple clients
attach to the same session, the behavior should be documented as using the
capabilities from session creation time.
### Pros
- Keeps `qwencode-viz` as one canonical bundled skill.
- Prevents host-specific output contracts from leaking into unsupported
clients.
- Creates a reusable mechanism for future renderer-specific or host-specific
skills.
- Makes the dependency explicit and testable.
### Cons
- Adds a new cross-cutting skill metadata field.
- Requires client/session capability plumbing across Web Shell, daemon, SDK, and
ACP surfaces.
- Needs careful documentation for shared-session behavior.
- May be more machinery than needed if `qwencode-viz` is the only expected
capability-gated skill.
## Option B: Client-Supplied Skill
Do not add a generic `required-capabilities` field. Instead, avoid bundling
`qwencode-viz` in core. The Web Shell client, or any client that supports the
renderer, supplies the skill itself.
Possible distribution models:
- The Web Shell host installs `.qwen/skills/qwencode-viz/SKILL.md`.
- The Web Shell package ships an optional non-auto-loaded skill template that a
host can copy or install when chart rendering is enabled.
- The Web Shell integration ships an extension skill package.
- The Web Shell integration injects equivalent model instructions only when its
chart renderer is enabled.
In this model, the skill is available only because the rendering client chose to
provide it.
### Pros
- Minimal core change.
- No new global skill metadata contract.
- Capability availability is naturally owned by the client that implements the
renderer.
- Avoids daemon or ACP plumbing unless the client already has a skill injection
mechanism.
### Cons
- No canonical bundled skill unless all clients copy the same content.
- More burden on each Web Shell integrator.
- Users moving between clients may see inconsistent skill availability.
- Does not create a general safeguard for future host-specific skills.
- Harder to test in core because availability depends on external installation
or injection.
## Recommendation
For this PR, use Option B.
That keeps the core skill system unchanged and avoids exposing
`echarts-fulldata` instructions in unsupported clients. The Web Shell renderer
hook remains useful for any host-owned block renderer, while chart-specific
model instructions become an explicit host opt-in.
Longer term, discuss this as a product/API boundary decision.
Choose Option A if maintainers expect Qwen Code to support more client-rendered
output contracts over time. In that case, `required-capabilities` is a small
general contract that keeps skill exposure honest across CLI, Web Shell, ACP,
and future clients.
Choose Option B if `qwencode-viz` is expected to remain a Web-Shell-only
extension and maintainers do not want core skills to depend on client rendering
features. In that case, the current bundled skill should be removed from core
and supplied by Web Shell clients that support `echarts-fulldata`.
The recommended future default is Option A only if maintainers are comfortable
making client/session capabilities part of the skill system. Otherwise, keep
host-renderer skills client-owned.
## Open Questions
- Should capabilities be session-scoped, request-scoped, or client-scoped?
- Should missing capabilities hide user-invocable commands, or only hide
model-invocable skill activation?
- Should capability names be free-form strings or validated against a known
registry?
- Should unavailable skills be hidden entirely from `/skills`, or shown as
disabled with a reason?
- Should there be a manual override for users who intentionally want to emit raw
`echarts-fulldata` blocks in unsupported clients?
- Should the field name be `required-capabilities`, `requires-capabilities`, or
`client-capabilities`?
## Validation Plan
If Option A is implemented, add tests for:
- Frontmatter parsing in both skill parsing paths.
- `collectAvailableSkillEntries` hiding a skill when capabilities are missing.
- The same skill appearing when capabilities are present.
- Interaction with `paths`, `skills.disabled`, and `disable-model-invocation`.
- `BundledSkillLoader` and `SkillCommandLoader` command visibility.
- Web Shell mapping from supported code block languages to client capabilities.
- Daemon or ACP session creation preserving the capability set.
- Existing bundled skill integration tests, to ensure skills without
`required-capabilities` are unchanged.
## Migration
Existing skills require no migration because the new field is optional.
For the current Option B path, remove the chart skill from core bundled skills.
The Web Shell package template must not be loaded by core automatically; hosts
opt in by installing or injecting it.
If Option A is accepted, add:
```yaml
required-capabilities:
- markdown.codeBlock.echarts-fulldata
```
to a future bundled `qwencode-viz`.
If Option B is accepted, remove the chart skill from core bundled skills and
document how Web Shell clients can install or inject it when they register an
`echarts-fulldata` renderer.

View file

@ -103,6 +103,48 @@ export function App() {
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
## 可选图表 Renderer
`WebShell` 支持宿主通过 `customization.markdown.renderCodeBlock` 接管特定
fenced code block 的渲染。图表类场景可以注册内置的
`echarts-fulldata` renderer
```tsx
import { createEchartsFullDataRenderer } from '@qwen-code/web-shell';
<WebShellWithProviders
markdown={{
renderCodeBlock: createEchartsFullDataRenderer({
loadEcharts: () => window.echarts,
resolveDataRef: async (ref, meta) =>
loadControlledChartDataset(ref, meta),
}),
}}
/>;
```
renderer 会把 `echarts-fulldata` code block 替换为图表卡片,并内置图表/数据
icon 切换ECharts runtime 由宿主通过 `loadEcharts` 提供。若启用
`data.kind="ref"` envelope数据只能通过宿主提供的 `resolveDataRef` 解析,
renderer 不会自己读取 URL 或本地路径。
如果需要让模型主动输出 `echarts-fulldata` block宿主应在自己的 skills 来源中
提供对应 skill并且只在确认当前 Web Shell 宿主已经注册 renderer 时启用。
`@qwen-code/web-shell` 不内置或自动加载这个 skill可从
`packages/web-shell/docs/examples/qwencode-viz/SKILL.md` 复制模板到宿主的
`.qwen/skills/qwencode-viz/SKILL.md`,或通过宿主自己的 skill 注入机制提供等价
说明。
`echarts-fulldata` 的 block body 可以是旧版纯 JSON ECharts option也可以是
`{ "version": 1, "data": ..., "option": ... }` envelope。新版 inline envelope
使用 `data.dimensions: string[]``data.source` array-of-arraysrenderer 会先
normalize 成原生 ECharts option并注入 `option.dataset`,再渲染图表和数据视图。
新版 ref envelope 必须使用受控 `artifact://``session-file://` ref并提供
`data.format``csv``json`)和 `data.dimensions`,这些元信息会传给宿主的
`resolveDataRef(ref, meta)`
宿主应使用 `JSON.parse` 解析,不能用 `eval``new Function` 或 script injection
执行模型生成内容。
## 架构说明
```text

View file

@ -0,0 +1,189 @@
.card {
margin: 10px 0;
min-width: 0;
max-width: 100%;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-radius: 8px;
background: var(--card, var(--background));
box-shadow: 0 8px 22px rgb(15 23 42 / 5%);
}
.toolbar {
display: flex;
min-height: 44px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 10px 0 14px;
background: color-mix(in srgb, var(--subtle-bg) 68%, var(--background));
border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
}
.title {
min-width: 0;
margin: 0;
color: var(--foreground);
font-size: 13px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toggle {
display: inline-grid;
flex: 0 0 auto;
grid-template-columns: repeat(2, 30px);
gap: 2px;
padding: 2px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: 6px;
background: var(--background);
}
.toggleButton {
display: grid;
width: 30px;
height: 26px;
place-items: center;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 0.16s ease,
color 0.16s ease;
}
.toggleButton:hover {
background: var(--subtle-bg-strong);
color: var(--foreground);
}
.toggleButton[aria-pressed='true'] {
background: var(--agent-blue-500, #0033ff);
color: #ffffff;
}
.toggleButton svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.toggleButton path {
fill: none;
stroke: currentColor;
stroke-width: 1.4;
}
.chartFrame {
position: relative;
min-height: 370px;
}
.chartSurface {
height: 370px;
min-height: 260px;
width: calc(100% - 20px);
margin: 0 10px 8px;
background: var(--background);
}
.chartOverlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--background) 88%, transparent);
}
.state {
display: grid;
min-height: 160px;
place-items: center;
padding: 16px;
color: var(--muted-foreground);
font-size: 13px;
text-align: center;
}
.loading {
min-height: 220px;
}
.spinner {
width: 22px;
height: 22px;
border: 2px solid var(--border);
border-top-color: var(--agent-blue-500, #0033ff);
border-radius: 999px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.tableWrap {
min-height: 240px;
max-height: min(60vh, 520px);
overflow: auto;
background: var(--card, var(--background));
scrollbar-color: var(--border) transparent;
}
.tableNotice {
position: sticky;
top: 0;
z-index: 2;
padding: 8px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
background: color-mix(in srgb, var(--subtle-bg) 72%, var(--background));
color: var(--muted-foreground);
font-size: 12px;
}
.table {
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
}
.table th,
.table td {
padding: 9px 12px;
border-right: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
text-align: left;
vertical-align: top;
}
.table th:last-child,
.table td:last-child {
border-right: 0;
}
.table th {
position: sticky;
top: 0;
z-index: 1;
background: color-mix(in srgb, var(--subtle-bg) 68%, var(--background));
color: var(--foreground);
font-size: 12px;
font-weight: 600;
}
.table td {
color: color-mix(in srgb, var(--foreground) 90%, var(--muted-foreground));
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -31,7 +31,7 @@ type TextFilterOperator =
| 'endsWith';
type NumberFilterOperator = 'gt' | 'gte' | 'lt' | 'lte' | 'between';
interface CellData {
export interface EnhancedTableCell {
key: string;
content: ReactNode;
text: string;
@ -39,14 +39,14 @@ interface CellData {
textAlign?: CSSProperties['textAlign'];
}
interface RowData {
export interface EnhancedTableRow {
key: string;
cells: CellData[];
cells: EnhancedTableCell[];
}
interface ParsedTable {
headers: CellData[];
rows: RowData[];
export interface EnhancedTableData {
headers: EnhancedTableCell[];
rows: EnhancedTableRow[];
columnCount: number;
}
@ -103,8 +103,8 @@ const NUMBER_FILTER_LABEL_KEYS: Record<NumberFilterOperator, string> = {
between: 'markdownTable.filter.number.between',
};
const MAX_ENHANCED_TABLE_ROWS = 500;
const MAX_ENHANCED_TABLE_COLUMNS = 50;
export const MAX_ENHANCED_TABLE_ROWS = 500;
export const MAX_ENHANCED_TABLE_COLUMNS = 50;
const FOCUSABLE_FILTER_MENU_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
@ -155,7 +155,7 @@ function getTextContent(node: ReactNode): string {
return '';
}
function emptyCell(rowKey: string, columnIndex: number): CellData {
function emptyCell(rowKey: string, columnIndex: number): EnhancedTableCell {
return {
key: `${rowKey}-empty-${columnIndex}`,
content: '',
@ -164,7 +164,7 @@ function emptyCell(rowKey: string, columnIndex: number): CellData {
};
}
function parseRow(rowNode: TableElement, rowKey: string): RowData {
function parseRow(rowNode: TableElement, rowKey: string): EnhancedTableRow {
const cellNodes = Children.toArray(rowNode.props.children).filter(
(child) => isTagElement(child, 'td') || isTagElement(child, 'th'),
);
@ -180,13 +180,19 @@ function parseRow(rowNode: TableElement, rowKey: string): RowData {
};
}
function parseRows(sectionNode: TableElement, prefix: string): RowData[] {
function parseRows(
sectionNode: TableElement,
prefix: string,
): EnhancedTableRow[] {
return Children.toArray(sectionNode.props.children)
.filter((child) => isTagElement(child, 'tr'))
.map((rowNode, rowIndex) => parseRow(rowNode, `${prefix}-${rowIndex}`));
}
function normalizeRow(row: RowData, columnCount: number): RowData {
function normalizeRow(
row: EnhancedTableRow,
columnCount: number,
): EnhancedTableRow {
return {
...row,
cells: Array.from(
@ -200,11 +206,11 @@ function normalizeRow(row: RowData, columnCount: number): RowData {
function parseTable(
children: ReactNode,
defaultColumnLabel: (columnIndex: number) => string,
): ParsedTable {
): EnhancedTableData {
const topLevel = Children.toArray(children);
const headerRows: RowData[] = [];
const bodyRows: RowData[] = [];
const directRows: RowData[] = [];
const headerRows: EnhancedTableRow[] = [];
const bodyRows: EnhancedTableRow[] = [];
const directRows: EnhancedTableRow[] = [];
topLevel.forEach((child, index) => {
if (isTagElement(child, 'thead')) {
@ -291,7 +297,7 @@ function sanitizeForClipboard(value: string): string {
function getSelectionText(
range: SelectionRange | null,
rows: RowData[],
rows: EnhancedTableRow[],
visibleColumnIndexes: number[],
): string {
if (!range) return '';
@ -317,8 +323,8 @@ function getSelectionText(
}
function getVisibleTableText(
headers: CellData[],
rows: RowData[],
headers: EnhancedTableCell[],
rows: EnhancedTableRow[],
visibleColumnIndexes: number[],
): string {
if (visibleColumnIndexes.length === 0) return '';
@ -422,10 +428,10 @@ function matchesColumnFilter(value: string, filter: ColumnFilter): boolean {
}
function applyFilters(
rows: RowData[],
rows: EnhancedTableRow[],
filters: Record<number, ColumnFilter>,
excludeColumnIndex?: number,
): RowData[] {
): EnhancedTableRow[] {
const activeFilters = Object.entries(filters)
.map(([key, value]) => [Number(key), value] as const)
.filter(
@ -441,7 +447,10 @@ function applyFilters(
);
}
function sortRows(rows: RowData[], sort: SortState | null): RowData[] {
function sortRows(
rows: EnhancedTableRow[],
sort: SortState | null,
): EnhancedTableRow[] {
if (!sort) return rows;
return rows
.map((row, index) => ({ row, index }))
@ -457,7 +466,7 @@ function sortRows(rows: RowData[], sort: SortState | null): RowData[] {
}
function getColumnOptions(
rows: RowData[],
rows: EnhancedTableRow[],
columnIndex: number,
blankLabel: string,
): FilterOption[] {
@ -476,7 +485,10 @@ function getColumnOptions(
.sort((a, b) => compareCellText(a.value, b.value));
}
function isMostlyNumericColumn(rows: RowData[], columnIndex: number): boolean {
function isMostlyNumericColumn(
rows: EnhancedTableRow[],
columnIndex: number,
): boolean {
let filledCount = 0;
let numericCount = 0;
rows.forEach((row) => {
@ -1038,14 +1050,14 @@ export function EnhancedMarkdownTable({
return <>{fallback ?? <table>{children}</table>}</>;
}
return <InteractiveMarkdownTable table={table} toolbarExtra={toolbarExtra} />;
return <EnhancedTable table={table} toolbarExtra={toolbarExtra} />;
}
function InteractiveMarkdownTable({
export function EnhancedTable({
table,
toolbarExtra,
}: {
table: ParsedTable;
table: EnhancedTableData;
toolbarExtra?: ReactNode;
}) {
const { t } = useI18n();

View file

@ -4,8 +4,12 @@
import { act, createElement, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WebShellCustomizationProvider } from '../../customization';
import {
WebShellCustomizationProvider,
type WebShellCodeBlockRenderInfo,
} from '../../customization';
import { I18nProvider } from '../../i18n';
import { ThemeProvider } from '../../themeContext';
import * as EnhancedTableModule from './EnhancedMarkdownTable';
import {
MAX_HIGHLIGHT_LINE_CHARS,
@ -261,6 +265,9 @@ describe('resolveFenceLanguage', () => {
expect(resolveFenceLanguage('ts').resolvedLang).toBe('typescript');
expect(resolveFenceLanguage('js').resolvedLang).toBe('javascript');
expect(resolveFenceLanguage('py').resolvedLang).toBe('python');
expect(resolveFenceLanguage('c++').resolvedLang).toBe('cpp');
expect(resolveFenceLanguage('c#').resolvedLang).toBe('csharp');
expect(resolveFenceLanguage('f#').resolvedLang).toBe('fsharp');
expect(resolveFenceLanguage('sh').resolvedLang).toBe('bash');
expect(resolveFenceLanguage('yml').resolvedLang).toBe('yaml');
expect(resolveFenceLanguage('golang').resolvedLang).toBe('go');
@ -334,6 +341,838 @@ describe('Markdown mermaid rendering', () => {
});
});
describe('Markdown custom code block rendering', () => {
it('lets host renderers replace assistant fenced code blocks', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.language !== 'echarts-fulldata') return undefined;
return createElement(
'div',
{ 'data-chart-theme': info.theme },
`${info.source}:${info.isStreaming}:${info.code}`,
);
});
await act(async () => {
root.render(
createElement(
ThemeProvider,
{ value: 'dark' },
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
isStreaming: true,
}),
),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledWith({
language: 'echarts-fulldata',
resolvedLanguage: 'text',
className: 'language-echarts-fulldata',
code: 'const option = {};',
isStreaming: true,
source: 'assistant',
theme: 'dark',
});
expect(container.querySelector('[data-chart-theme="dark"]')).not.toBeNull();
expect(container.textContent).toContain(
'assistant:true:const option = {};',
);
expect(container.querySelector('pre code')).toBeNull();
await act(async () => {
root.unmount();
});
container.remove();
});
it('falls back to the default code block when the host renderer declines', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => undefined);
await act(async () => {
root.render(
createElement(
ThemeProvider,
{ value: 'light' },
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```custom-chart\nconst option = {};\n```',
source: 'assistant',
isStreaming: false,
}),
),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({
language: 'custom-chart',
resolvedLanguage: 'text',
className: 'language-custom-chart',
code: 'const option = {};',
theme: 'light',
}),
);
expect(container.textContent).toContain('custom-chart');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('passes resolved language aliases to host renderers', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => undefined);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```ts\nconst x = 1;\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({
language: 'ts',
resolvedLanguage: 'typescript',
}),
);
expect(container.querySelector('pre code')?.textContent).toContain(
'const x = 1;',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('passes punctuation language aliases to host renderers', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => undefined);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```c++\nstd::cout << "hello";\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({
language: 'c++',
resolvedLanguage: 'cpp',
}),
);
expect(container.querySelector('pre code')?.textContent).toContain(
'std::cout << "hello";',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('extracts language prefixes from glued fence metadata', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => undefined);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content:
'```js{1,3}\nconst x = 1;\n```\n\n```c:main.c\nint main() {}\n```\n\n```vue{2}\n<template />\n```',
source: 'assistant',
}),
),
);
});
const infos = renderCodeBlock.mock.calls.map(
([info]) => info as WebShellCodeBlockRenderInfo,
);
expect(infos.map((info) => info.language)).toEqual(['js', 'c', 'vue']);
expect(infos.map((info) => info.resolvedLanguage)).toEqual([
'javascript',
'c',
'vue',
]);
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not pass unsafe fence-language characters to host renderers', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```bad<script>\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('falls back to the default code block when the host renderer returns null', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => null);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```custom-chart\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledOnce();
expect(container.textContent).toContain('custom-chart');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('falls back to the default code block when the host renderer returns false', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => false);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```custom-chart\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalledOnce();
expect(container.textContent).toContain('custom-chart');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not call host renderers when markdown source is omitted', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not call host renderers for inline code', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: 'Inline `const x = 1` example.',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('code')?.textContent).toBe('const x = 1');
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not call host renderers for bare fenced code blocks', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```\nhello world\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('pre code')?.textContent).toContain(
'hello world',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('does not call host renderers for unfenced multiline code blocks', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: ' const x = 1;\n const y = 2;',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('pre code')?.textContent).toContain(
'const x = 1;',
);
await act(async () => {
root.unmount();
});
container.remove();
});
it('falls back to the default code block when the host renderer throws', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => {
throw new Error('boom');
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block renderer call failed (lang=%s):',
'echarts-fulldata',
expect.any(Error),
);
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('falls back to the default code block when custom rendered content throws', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement(ThrowingChart));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('retries custom rendered content after the error boundary reset key changes', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.code === 'bad') return createElement(ThrowingChart);
return createElement('div', { 'data-custom-code': info.code }, info.code);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nbad\n```',
source: 'assistant',
}),
),
);
});
expect(container.querySelector('pre code')?.textContent).toContain('bad');
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\ngood\n```',
source: 'assistant',
}),
),
);
});
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(
container.querySelector('[data-custom-code="good"]'),
).not.toBeNull();
expect(container.querySelector('pre code')).toBeNull();
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('retries custom rendered content when streaming settles', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.isStreaming) return createElement(ThrowingChart);
return createElement('div', { 'data-custom-code': 'settled' }, info.code);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nfinal\n```',
source: 'assistant',
isStreaming: true,
}),
),
);
});
expect(container.querySelector('pre code')?.textContent).toContain(
'final',
);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nfinal\n```',
source: 'assistant',
isStreaming: false,
}),
),
);
});
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(
container.querySelector('[data-custom-code="settled"]'),
).not.toBeNull();
expect(container.querySelector('pre code')).toBeNull();
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('retries custom rendered content as streaming code changes', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.code === 'bad') return createElement(ThrowingChart);
return createElement('div', { 'data-custom-code': info.code }, info.code);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nbad\n```',
source: 'assistant',
isStreaming: true,
}),
),
);
});
expect(container.querySelector('pre code')?.textContent).toContain('bad');
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\ngood\n```',
source: 'assistant',
isStreaming: true,
}),
),
);
});
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(
container.querySelector('[data-custom-code="good"]'),
).not.toBeNull();
expect(container.querySelector('pre code')).toBeNull();
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('retries custom rendered content when source or theme changes', async () => {
function ThrowingChart(): never {
throw new Error('render boom');
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => {
if (info.source === 'assistant' && info.theme === 'dark') {
return createElement(ThrowingChart);
}
return createElement(
'div',
{ 'data-custom-code': `${info.source}:${info.theme}` },
info.code,
);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const tree = (source: 'assistant' | 'thinking', theme: 'dark' | 'light') =>
createElement(
ThemeProvider,
{ value: theme },
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nsame\n```',
source,
}),
),
);
try {
await act(async () => {
root.render(tree('assistant', 'dark'));
});
expect(container.querySelector('pre code')?.textContent).toContain(
'same',
);
await act(async () => {
root.render(tree('thinking', 'light'));
});
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block component render (lang=echarts-fulldata) failed:',
expect.any(Error),
expect.any(String),
);
expect(
container.querySelector('[data-custom-code="thinking:light"]'),
).not.toBeNull();
expect(container.querySelector('pre code')).toBeNull();
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
it('lets custom code components take precedence over renderCodeBlock', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{
value: {
markdown: {
renderCodeBlock,
components: {
code({ children }: { children?: ReactNode }) {
return createElement(
'code',
{ 'data-custom-code-component': 'true' },
children,
);
},
},
},
},
},
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});
expect(renderCodeBlock).not.toHaveBeenCalled();
expect(
container.querySelector('[data-custom-code-component="true"]'),
).not.toBeNull();
await act(async () => {
root.unmount();
});
container.remove();
});
it('applies transformMarkdown customization before rendering', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const transformMarkdown = vi.fn((content: string) =>
content.replace('raw chart', 'transformed chart'),
);
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { transformMarkdown } } },
createElement(Markdown, {
content: '**raw chart**',
source: 'assistant',
}),
),
);
});
expect(transformMarkdown).toHaveBeenCalledWith('**raw chart**', {
source: 'assistant',
});
expect(container.textContent).toContain('transformed chart');
expect(container.textContent).not.toContain('raw chart');
await act(async () => {
root.unmount();
});
container.remove();
});
});
describe('Markdown code highlighting while streaming', () => {
it('keeps streamed code content visible while streaming', async () => {
const container = document.createElement('div');

View file

@ -1,12 +1,10 @@
import {
Component,
createContext,
memo,
useContext,
useEffect,
useMemo,
useState,
type ErrorInfo,
type ReactNode,
} from 'react';
import { useTheme } from '../../themeContext';
@ -27,6 +25,7 @@ import {
type MarkdownTableMode,
type MarkdownContentSource,
} from '../../customization';
import { ErrorBoundary } from '../ErrorBoundary';
import { EnhancedMarkdownTable } from './EnhancedMarkdownTable';
import styles from './Markdown.module.css';
@ -52,6 +51,7 @@ const SUPPORTED_LANGUAGES = new Set([
'c',
'cpp',
'csharp',
'fsharp',
'ruby',
'php',
'swift',
@ -90,10 +90,13 @@ const SUPPORTED_LANGUAGES = new Set([
'diff',
]);
// Common fence aliases → Shiki's canonical language id. Without this, blocks
// tagged ```ts / ```js / ```py fall through to the unhighlighted "text" path
// even though Shiki supports them under their full names.
// Common fence aliases → Shiki's canonical language id. This keeps shorthand
// tags like ```ts and punctuation tags like ```c++ highlighted under the
// language ids Shiki actually supports.
const LANGUAGE_ALIASES: Record<string, string> = {
'c++': 'cpp',
'c#': 'csharp',
'f#': 'fsharp',
ts: 'typescript',
js: 'javascript',
py: 'python',
@ -290,8 +293,9 @@ function CodeBlock({
const [html, setHtml] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const match = className?.match(/language-(\w+)/);
const { label, lang, resolvedLang } = resolveFenceLanguage(match?.[1]);
const { label, lang, resolvedLang } = resolveFenceLanguage(
extractRawFenceLanguage(className),
);
const code = String(children).replace(/\n$/, '');
const shikiTheme =
appTheme === 'light' ? 'github-light-default' : 'github-dark-default';
@ -410,6 +414,15 @@ function CodeBlock({
);
}
function extractRawFenceLanguage(className: string | undefined): string {
const token = className?.match(/(?:^|\s)language-([^\s]+)/)?.[1] ?? '';
const match = token.match(/^([\w+.#-]+)/);
if (!match) return '';
const language = match[1] ?? '';
const nextChar = token[language.length];
return !nextChar || nextChar === '{' || nextChar === ':' ? language : '';
}
function InlineCode({ children }: { children: ReactNode }) {
return <code className={styles.inlineCode}>{children}</code>;
}
@ -422,45 +435,15 @@ function PlainMarkdownTable({ children }: { children?: ReactNode }) {
);
}
class EnhancedMarkdownTableBoundary extends Component<
{ children: ReactNode; fallback: ReactNode; resetKey: string },
{ hasError: boolean; resetKey: string }
> {
state = { hasError: false, resetKey: this.props.resetKey };
static getDerivedStateFromError() {
return { hasError: true };
}
static getDerivedStateFromProps(
props: { resetKey: string },
state: { resetKey: string },
) {
if (props.resetKey !== state.resetKey) {
return { hasError: false, resetKey: props.resetKey };
}
return null;
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error(
'[web-shell] enhanced markdown table failed:',
error,
errorInfo.componentStack,
);
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
// Carries the streaming flag to CodeBlock via context instead of a closure, so
// the `code` renderer below can be a single stable reference. Toggling
// isStreaming then no longer changes the `code` element type, so React reuses
// the same CodeBlock instance across the streaming→settled transition
// (preserving its highlighted `html` state) instead of remounting it.
const IsStreamingContext = createContext(false);
const MarkdownSourceContext = createContext<MarkdownContentSource | undefined>(
undefined,
);
function MarkdownCode({
className,
@ -476,14 +459,77 @@ function MarkdownCode({
if (isBlock) {
return (
<CodeBlock className={className} isStreaming={isStreaming}>
{String(children)}
</CodeBlock>
<MarkdownFencedCode className={className} isStreaming={isStreaming}>
{children}
</MarkdownFencedCode>
);
}
return <InlineCode>{children}</InlineCode>;
}
function MarkdownFencedCode({
className,
children,
isStreaming,
}: {
className?: string;
children?: ReactNode;
isStreaming?: boolean;
}) {
const source = useContext(MarkdownSourceContext);
const appTheme = useTheme();
const { markdown } = useWebShellCustomization();
const rawCode = String(children);
const code = rawCode.replace(/\n$/, '');
const fallback = (
<CodeBlock className={className} isStreaming={isStreaming}>
{rawCode}
</CodeBlock>
);
const language = extractRawFenceLanguage(className);
const { resolvedLang: resolvedLanguage } = resolveFenceLanguage(language);
const canUseCustomRenderer = !!source && !!className && !!language;
if (canUseCustomRenderer) {
try {
const custom = markdown?.renderCodeBlock?.({
language,
resolvedLanguage,
className,
code,
isStreaming: !!isStreaming,
source,
theme: appTheme,
});
if (custom != null && typeof custom !== 'boolean') {
return (
<ErrorBoundary
fallback={fallback}
label={`custom code block component render (lang=${language})`}
resetKeys={[
language,
source,
appTheme,
isStreaming ? 'streaming' : 'settled',
code,
]}
>
{custom}
</ErrorBoundary>
);
}
} catch (error) {
console.error(
'[web-shell] custom code block renderer call failed (lang=%s):',
language,
error,
);
}
}
return fallback;
}
function MarkdownPre({ children }: { children?: ReactNode }) {
return <>{children}</>;
}
@ -530,14 +576,15 @@ function createComponents(
if (tableMode === 'advanced') {
const fallback = <PlainMarkdownTable>{children}</PlainMarkdownTable>;
return (
<EnhancedMarkdownTableBoundary
<ErrorBoundary
fallback={fallback}
resetKey={tableResetKey}
label="enhanced markdown table"
resetKeys={[tableResetKey]}
>
<EnhancedMarkdownTable fallback={fallback}>
{children}
</EnhancedMarkdownTable>
</EnhancedMarkdownTableBoundary>
</ErrorBoundary>
);
}
return <PlainMarkdownTable>{children}</PlainMarkdownTable>;
@ -592,13 +639,15 @@ export const Markdown = memo(function Markdown({
data-markdown-source={source}
>
<IsStreamingContext.Provider value={!!isStreaming}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={renderedComponents}
>
{renderedContent}
</ReactMarkdown>
<MarkdownSourceContext.Provider value={source}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={renderedComponents}
>
{renderedContent}
</ReactMarkdown>
</MarkdownSourceContext.Provider>
</IsStreamingContext.Provider>
</div>
);

View file

@ -8,6 +8,7 @@ import type { Components, Options } from 'react-markdown';
import type { DaemonStreamingState } from '@qwen-code/webui/daemon-react-sdk';
import type { ACPToolCall } from './adapters/types';
import type { WelcomeHeaderProps } from './components/WelcomeHeader';
import type { WebShellTheme } from './themeContext';
export type MarkdownContentSource = 'assistant' | 'thinking';
@ -15,11 +16,46 @@ export interface MarkdownRenderContext {
source: MarkdownContentSource;
}
export interface WebShellCodeBlockRenderInfo {
/**
* Raw fenced-code language from the markdown class name, restricted to safe
* fence-language characters.
*/
language: string;
/**
* Canonical Shiki language id after applying built-in aliases, or `text`
* when the language is unsupported by the fallback highlighter.
*/
resolvedLanguage: string;
className?: string;
code: string;
/** True while the assistant message is still streaming partial content. */
isStreaming: boolean;
source: MarkdownContentSource;
theme: WebShellTheme;
}
/**
* Return a React node to replace the default code block rendering. Return
* `null`, `undefined`, or `false` to decline and fall back to the built-in code
* block renderer. Expensive renderers should debounce or defer work while
* `info.isStreaming` is true.
*/
export type CodeBlockRenderer = (
info: WebShellCodeBlockRenderInfo,
) => ReactNode | null | undefined;
export interface WebShellMarkdownCustomization {
transformMarkdown?: (
markdown: string,
context: MarkdownRenderContext,
) => string;
renderCodeBlock?: CodeBlockRenderer;
/**
* Custom markdown components override Web Shell's built-ins. In particular,
* `components.code` replaces the default code renderer, so `renderCodeBlock`
* will not be called for that source.
*/
components?: Components;
remarkPlugins?: Options['remarkPlugins'];
rehypePlugins?: Options['rehypePlugins'];
@ -28,14 +64,7 @@ export interface WebShellMarkdownCustomization {
export type MarkdownTableMode = 'basic' | 'advanced';
export type ToolHeaderKind =
| 'agent'
| 'edit'
| 'fetch'
| 'read'
| 'shell'
| 'todo'
| 'write'
| 'other';
'agent' | 'edit' | 'fetch' | 'read' | 'shell' | 'todo' | 'write' | 'other';
export interface ToolHeaderExtraRenderInfo {
kind: ToolHeaderKind;
@ -155,9 +184,7 @@ export interface WebShellMonitorTask extends WebShellTaskBase {
}
export type WebShellTaskInfo =
| WebShellAgentTask
| WebShellShellTask
| WebShellMonitorTask;
WebShellAgentTask | WebShellShellTask | WebShellMonitorTask;
// ---- Model info (public type for footer renderer) ----

View file

@ -10,8 +10,7 @@ export const WEB_SHELL_LANGUAGES = ['en', 'zh-CN'] as const;
export type WebShellLanguage = (typeof WEB_SHELL_LANGUAGES)[number];
type MessageValue =
| string
| ((vars?: Record<string, string | number>) => string);
string | ((vars?: Record<string, string | number>) => string);
type Messages = Record<string, MessageValue>;
@ -214,6 +213,27 @@ const EN: Messages = {
`${v?.label ?? 'Selection'} copied to the clipboard`,
'code.copy': 'Copy',
'code.copied': 'Copied!',
'echartsChart.defaultTitle': 'Chart Loading',
'echartsChart.noData': 'No data',
'echartsChart.tableNotice': (v) => {
const omittedRows = Number(v?.omittedRows ?? 0);
const omittedColumns = Number(v?.omittedColumns ?? 0);
if (omittedRows > 0 && omittedColumns > 0) {
return `Showing ${v?.visibleRows ?? 0} of ${v?.totalRows ?? 0} rows and ${v?.visibleColumns ?? 0} of ${v?.totalColumns ?? 0} columns`;
}
if (omittedColumns > 0) {
return `Showing ${v?.visibleColumns ?? 0} of ${v?.totalColumns ?? 0} columns`;
}
return `Showing ${v?.visibleRows ?? 0} of ${v?.totalRows ?? 0} rows`;
},
'echartsChart.rendering': 'Rendering chart',
'echartsChart.runtimeUnavailable': 'Chart runtime is unavailable.',
'echartsChart.renderFailed': 'Chart render failed.',
'echartsChart.viewMode': 'View mode',
'echartsChart.showChart': 'Show chart',
'echartsChart.showData': 'Show data',
'echartsChart.chart': 'Chart',
'echartsChart.data': 'Data',
'markdownTable.blank': '(blank)',
'markdownTable.column': (v) => `Column ${v?.index ?? ''}`,
'markdownTable.rows': (v) => {
@ -1550,6 +1570,27 @@ const ZH: Messages = {
'copy.toClipboard': (v) => `${v?.label ?? '内容'} 已复制到剪贴板`,
'code.copy': '复制',
'code.copied': '已复制!',
'echartsChart.defaultTitle': '图表加载中',
'echartsChart.noData': '暂无数据',
'echartsChart.tableNotice': (v) => {
const omittedRows = Number(v?.omittedRows ?? 0);
const omittedColumns = Number(v?.omittedColumns ?? 0);
if (omittedRows > 0 && omittedColumns > 0) {
return `显示 ${v?.visibleRows ?? 0}/${v?.totalRows ?? 0} 行,${v?.visibleColumns ?? 0}/${v?.totalColumns ?? 0}`;
}
if (omittedColumns > 0) {
return `显示 ${v?.visibleColumns ?? 0}/${v?.totalColumns ?? 0}`;
}
return `显示 ${v?.visibleRows ?? 0}/${v?.totalRows ?? 0}`;
},
'echartsChart.rendering': '正在渲染图表',
'echartsChart.runtimeUnavailable': '图表运行时不可用。',
'echartsChart.renderFailed': '图表渲染失败。',
'echartsChart.viewMode': '视图模式',
'echartsChart.showChart': '显示图表',
'echartsChart.showData': '显示数据',
'echartsChart.chart': '图表',
'echartsChart.data': '数据',
'markdownTable.blank': '(空白)',
'markdownTable.column': (v) => `${v?.index ?? ''}`,
'markdownTable.rows': (v) => `${v?.count ?? 0}`,

View file

@ -3,7 +3,9 @@ export type { WebShellProps, BugReportInfo } from './App';
export type { ComposerToolbarAction } from './components/ChatEditor';
export type { ToastTone } from './components/ToastHost';
export type { WebShellLanguage } from './i18n';
export type { WebShellTheme } from './themeContext';
export type {
CodeBlockRenderer,
MarkdownContentSource,
MarkdownRenderContext,
MarkdownTableMode,
@ -28,6 +30,7 @@ export type {
WebShellFooterRenderInfo,
FooterRenderer,
LoadingPhrasesResolver,
WebShellCodeBlockRenderInfo,
WebShellTaskInfo,
WebShellAgentTask,
WebShellShellTask,
@ -36,3 +39,20 @@ export type {
WebShellSkillInfo,
} from './customization';
export type { WelcomeHeaderProps } from './components/WelcomeHeader';
export {
ECHARTS_FULLDATA_LANGUAGE,
EchartsFullDataBlock,
createEchartsFullDataRenderer,
} from './components/messages/EchartsFullDataBlock';
export type {
DatasetCell,
EchartsFullDataBlockProps,
EchartsFullDataOption,
EchartsFullDataRefMeta,
EchartsFullDataRefResolver,
EchartsFullDataResolvedDataset,
EchartsFullDataRendererOptions,
EchartsInstance,
EchartsRuntime,
EchartsRuntimeLoader,
} from './components/messages/EchartsFullDataBlock';

View file

@ -108,6 +108,7 @@ export type {
} from './utils/commandDisplay';
export type { ComposerToolbarAction } from './components/ChatEditor';
export type {
CodeBlockRenderer,
MarkdownContentSource,
MarkdownTableMode,
MarkdownRenderContext,
@ -121,6 +122,24 @@ export type {
WebShellComposerToolbarRightRenderInfo,
WelcomeFooterRenderer,
WelcomeHeaderRenderer,
WebShellCodeBlockRenderInfo,
WebShellMarkdownCustomization,
} from './customization';
export type { WelcomeHeaderProps } from './components/WelcomeHeader';
export {
ECHARTS_FULLDATA_LANGUAGE,
EchartsFullDataBlock,
createEchartsFullDataRenderer,
} from './components/messages/EchartsFullDataBlock';
export type {
DatasetCell,
EchartsFullDataBlockProps,
EchartsFullDataOption,
EchartsFullDataRefMeta,
EchartsFullDataRefResolver,
EchartsFullDataResolvedDataset,
EchartsFullDataRendererOptions,
EchartsInstance,
EchartsRuntime,
EchartsRuntimeLoader,
} from './components/messages/EchartsFullDataBlock';

View file

@ -0,0 +1,122 @@
---
name: qwencode-viz
description: >
Generate renderable chart outputs for clients that support the Qwen Code Web
Shell echarts-fulldata renderer. Use only when the current Web Shell host has
explicitly registered that renderer and the user asks for a chart,
visualization, ECharts output, or Web Shell-rendered chart block.
---
# Qwen Code Visualization Skill
Use this skill to emit chart blocks that a Qwen Code Web Shell host can render.
This skill defines only the model output contract; it does not load or execute
the chart runtime. The host client must already have registered an
`echarts-fulldata` renderer.
## Preconditions
Use this skill only when all of these conditions are true:
- The active host is Qwen Code Web Shell, or an equivalent Web Shell client.
- The host has registered an `echarts-fulldata` fenced code block renderer.
- The user wants a visual chart, not just a plain Markdown table or code block.
If any condition is not met, do not emit an `echarts-fulldata` block. Use normal
Markdown, tables, or prose instead.
## Output Contract
Emit one fenced code block whose language tag is exactly `echarts-fulldata`.
The block body must be one valid JSON object that can be parsed directly with
`JSON.parse`. Do not emit JavaScript.
Preferred inline payload shape:
```echarts-fulldata
{
"version": 1,
"data": {
"kind": "inline",
"dimensions": ["day", "orders"],
"source": [
["Mon", 120],
["Tue", 200],
["Wed", 150],
["Thu", 80],
["Fri", 240]
]
},
"option": {
"title": { "text": "Weekly orders" },
"tooltip": { "trigger": "axis" },
"xAxis": { "type": "category" },
"yAxis": { "type": "value" },
"series": [{ "type": "bar", "encode": { "x": "day", "y": "orders" } }]
}
}
```
Legacy dataset-backed ECharts option JSON is also accepted when an envelope is
not needed:
```echarts-fulldata
{
"title": { "text": "Weekly orders" },
"dataset": {
"dimensions": ["day", "orders"],
"source": [
{ "day": "Mon", "orders": 120 },
{ "day": "Tue", "orders": 200 }
]
},
"xAxis": { "type": "category" },
"yAxis": { "type": "value" },
"series": [{ "type": "bar", "encode": { "x": "day", "y": "orders" } }]
}
```
## Safety Rules
- Output JSON data only, not JavaScript.
- Do not output `const option = ...`, expressions, comments, trailing commas,
functions, or callbacks.
- Do not ask the host to use `eval`, `new Function`, or script injection.
- Do not reference the DOM, globals, network requests, randomness, timers,
`document`, `window`, or the filesystem.
- Put chart data in the envelope `data.source`, or in `dataset.source` for
legacy option payloads.
- Avoid duplicating the same data in `xAxis.data`, `legend.data`, or
`series.data` when `dataset` plus `encode` can express it.
- If the data is too large, aggregate or sample it first, and explain that
treatment outside the block.
## Response Format
When a chart is appropriate, respond in this order:
1. One short takeaway describing the main point shown by the chart.
2. One `echarts-fulldata` fenced code block containing the complete JSON
payload.
3. Optional notes such as metric definitions, aggregation choices, or reading
guidance.
Do not nest the chart block inside any other Markdown container.
## Chart Guidance
- Trends: Prefer a line chart with time on the x-axis and the metric on the
y-axis.
- Rankings: Prefer a bar chart sorted by the metric in descending order.
- Composition: Use a pie chart for a small number of categories; use a bar chart
when there are many categories.
- Multi-metric comparisons: Prefer grouped bars or multiple lines, and avoid
overcrowding the chart with too many series.
- Keep titles, axes, units, and legends clear.
## When Unsure
If there is not enough data to draw a chart, or if renderer support is unclear,
explain the reason in normal Markdown first. Do not guess by emitting an
`echarts-fulldata` block.

View file

@ -13,7 +13,8 @@
},
"files": [
"dist/index.js",
"dist/types"
"dist/types",
"docs/examples/qwencode-viz/SKILL.md"
],
"scripts": {
"dev": "vite",