mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
90e1e3d47e
commit
e9a7917d5e
14 changed files with 6673 additions and 95 deletions
342
docs/design/skill-required-capabilities.md
Normal file
342
docs/design/skill-required-capabilities.md
Normal 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.
|
||||
|
|
@ -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-arrays;renderer 会先
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) ----
|
||||
|
||||
|
|
|
|||
|
|
@ -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} 行`,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
122
packages/web-shell/docs/examples/qwencode-viz/SKILL.md
Normal file
122
packages/web-shell/docs/examples/qwencode-viz/SKILL.md
Normal 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.
|
||||
|
|
@ -13,7 +13,8 @@
|
|||
},
|
||||
"files": [
|
||||
"dist/index.js",
|
||||
"dist/types"
|
||||
"dist/types",
|
||||
"docs/examples/qwencode-viz/SKILL.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue