diff --git a/docs/design/markdown-chart-skill-integration.md b/docs/design/markdown-chart-skill-integration.md new file mode 100644 index 00000000000..02b69ec8d2b --- /dev/null +++ b/docs/design/markdown-chart-skill-integration.md @@ -0,0 +1,79 @@ +# Markdown Chart Skill Integration + +Status: accepted + +## Integration contract + +Qwen Code WebShell owns the rendering side of the contract: + +- `@qwen-code/web-shell` includes the `markdown-chart` renderer and ECharts + runtime. +- Hosts install the canonical + [`markdown-chart` skill](https://github.com/datafe/markdown-chart/tree/main/skills/markdown-chart) + so the model emits renderable chart blocks. +- Qwen Code core does not bundle or inject the skill. A project can install it + at `.qwen/skills/markdown-chart/SKILL.md`; user-level skill installation is + also supported. + +For the normal `data.kind="inline"` output produced by the skill, the WebShell +host needs no chart-specific code: + +```tsx +import { WebShellWithProviders } from '@qwen-code/web-shell'; + +; +``` + +## Referenced data + +If the host exposes real controlled datasets to the skill and allows +`data.kind="ref"`, it provides `resolveDataRef` through a custom registry: + +```tsx +import { + createMarkdownChartRegistry, + WebShellWithProviders, +} from '@qwen-code/web-shell'; + +const chartRegistry = createMarkdownChartRegistry({ + resolveDataRef: async (ref, context) => + loadControlledChartDataset(ref, context), +}); +const markdown = { chart: { registry: chartRegistry } }; + +; +``` + +The renderer never fetches a ref or reads a local path by itself. +`resolveDataRef` is the host-owned boundary from a model-visible reference to a +trusted dataset. The default registry accepts normalized `artifact://` and +`session-file://` refs, parses the block as JSON, validates the option, and then +passes the normalized ref plus declared format and dimensions to the resolver. +Resolver waits are bounded to 30 seconds. Keep `markdown`, `chart`, and +`labels` overrides referentially stable while charts are mounted. + +## Streaming behavior + +The shared React adapter distinguishes a closed chart fence from the active +unterminated tail fence: + +- A closed `markdown-chart` block renders immediately and remains mounted while + later answer text streams, including when the fence is inside a blockquote. +- Only the active unterminated chart fence displays the loading state. + +## Scope + +- The skill defines the model output contract; it does not load the renderer. +- WebShell defines the rendering contract; it does not automatically install + the skill. +- No daemon, ACP, or client-capability negotiation change is required. +- No automatic network or filesystem access is introduced. diff --git a/docs/design/skill-required-capabilities.md b/docs/design/skill-required-capabilities.md deleted file mode 100644 index 92ae16524f6..00000000000 --- a/docs/design/skill-required-capabilities.md +++ /dev/null @@ -1,466 +0,0 @@ -# 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; -} -``` - -Expose a helper on `Config`, for example: - -```ts -config.getClientCapabilities(): ReadonlySet -``` - -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 - -``` - -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. - -### Web Shell Host Integration - -A Web Shell host that wants chart output should opt in to both halves of the -contract: - -1. Register an `echarts-fulldata` Markdown code block renderer. -2. Provide the matching chart skill from - `packages/web-shell/docs/examples/qwencode-viz/SKILL.md`. - -For example: - -```tsx -import * as echarts from 'echarts'; -import { - WebShellWithProviders, - createEchartsFullDataRenderer, -} from '@qwen-code/web-shell'; - - echarts, - resolveDataRef: async (ref, meta) => - loadControlledChartDataset(ref, meta), - }), - }} -/>; -``` - -In this renderer configuration, `loadEcharts` lets the host provide the -approved ECharts runtime, either as a static import or a lazy-loaded module. -`resolveDataRef` is only used for `data.kind="ref"` chart blocks; it is the -host-owned bridge from a model-visible data reference to a trusted dataset. -The model-facing envelope format is described by the optional skill template in -`packages/web-shell/docs/examples/qwencode-viz/SKILL.md`; the renderer-side -validation lives in -`packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx`. - -The skill file should be installed or injected only by hosts that perform this -registration. A simple file-based integration can copy: - -```text -packages/web-shell/docs/examples/qwencode-viz/SKILL.md -``` - -to the workspace or user skill directory, for example: - -```text -.qwen/skills/qwencode-viz/SKILL.md -``` - -An integration with its own skill distribution layer can instead load the same -file as the canonical source content and expose it through that layer. In both -cases, core does not auto-load the skill; the host owns enabling it because the -host owns the renderer. - -For `data.kind="ref"` envelopes, the built-in renderer validates that `data.ref` -uses a normalized `artifact://` or `session-file://` reference before it calls -the host-controlled `resolveDataRef(ref, meta)` implementation. The renderer -also parses the block as JSON and sanitizes the ECharts option before rendering; -it does not evaluate model-provided JavaScript, fetch arbitrary URLs, or read -local files by itself. A custom renderer should preserve the same split: -renderer-level JSON/ref/option validation first, host-owned artifact resolution -second. - -A daemon-backed host can treat the workspace file API as one artifact backend. -For example, the host can persist chart artifacts under a controlled workspace -directory such as `.qwen/artifacts/`, expose model-facing references like -`artifact://chart-data/orders.csv`, and resolve them through daemon -`GET /file?path=.qwen/artifacts/chart-data/orders.csv`. This keeps -`artifact://` as the public chart contract while allowing the first -implementation to reuse daemon workspace files. - -The resolver must still enforce the artifact root before calling the daemon: - -```tsx -const ARTIFACT_ROOT = '.qwen/artifacts/'; -const MAX_CHART_DATA_BYTES = 256 * 1024; - -async function resolveDataRef( - ref: string, - meta: { format?: string; dimensions?: string[] }, -) { - const artifactPrefix = 'artifact://'; - if (!ref.startsWith(artifactPrefix)) { - throw new Error(`Unsupported chart data ref: ${ref}`); - } - - const artifactPath = ref.slice(artifactPrefix.length); - if ( - artifactPath.length === 0 || - artifactPath.startsWith('/') || - artifactPath.includes('\\') || - artifactPath.split('/').includes('..') - ) { - throw new Error(`Invalid chart data ref: ${ref}`); - } - - const url = new URL('/file', daemonBaseUrl); - url.searchParams.set('path', `${ARTIFACT_ROOT}${artifactPath}`); - url.searchParams.set('maxBytes', String(MAX_CHART_DATA_BYTES)); - - const response = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }); - if (!response.ok) { - throw new Error(`Failed to read chart data: ${response.status}`); - } - - const file = (await response.json()) as { content: string }; - return meta.format === 'csv' - ? parseCsvAsArrayRows(file.content, meta.dimensions) - : JSON.parse(file.content); -} -``` - -This example intentionally maps only normalized `artifact://` paths under -`.qwen/artifacts/`. If a host later moves artifacts to object storage or a -session-scoped artifact service, only `resolveDataRef` needs to change; the -model-facing `echarts-fulldata` block can keep using the same ref shape. - -### 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. diff --git a/docs/design/web-shell-markdown-chart.md b/docs/design/web-shell-markdown-chart.md new file mode 100644 index 00000000000..6754b47affd --- /dev/null +++ b/docs/design/web-shell-markdown-chart.md @@ -0,0 +1,106 @@ +# Web Shell Markdown Chart Integration + +## Problem + +Qwen Code Web Shell currently owns a standalone `echarts-fulldata` parser, +sanitizer, data-ref resolver, chart lifecycle, styling, and Chart/Data view in +`EchartsFullDataBlock.tsx`. The same behavior is now maintained and published +by `@datafe-open/markdown-chart*`. + +Keeping both implementations makes the Web Shell renderer drift from the shared +Markdown chart protocol and duplicates security, streaming, and ECharts +lifecycle fixes. In particular, Web Shell only knows whether the whole +assistant message is streaming, while the shared component distinguishes a +closed chart fence from the active incomplete tail fence. + +## Proposed changes + +### Shared renderer + +- Wrap Web Shell's existing `ReactMarkdown` with + `@datafe-open/markdown-chart-react`'s provider and route registered chart + fences through its `pre` adapter and block component. +- Build the default ECharts registry with + `@datafe-open/markdown-chart` and + `@datafe-open/markdown-chart-echarts`. +- Enable a stable default ECharts registry inside Web Shell, so embedded and + standalone consumers render canonical `markdown-chart` fences and registered + aliases, including `echarts-fulldata`, without importing ECharts or supplying + host customization. +- Keep `markdown.chart.registry` and `createMarkdownChartRegistry` as explicit + override APIs for controlled data refs, custom labels/loading/error behavior, + or a host-selected runtime. +- Keep `createEchartsFullDataRenderer` and `EchartsFullDataBlock` as deprecated + compatibility surfaces. They delegate rendering to the shared component + instead of retaining a second parser or chart lifecycle. + +### Streaming boundary + +- Preserve `WebShellCodeBlockRenderInfo.isStreaming` as the whole-message state + used by existing custom renderers. +- Let the shared React adapter derive per-fence completeness from the full + transformed Markdown source and AST offsets. Closed chart fences therefore + render immediately and remain mounted while later text streams. +- Also expose `isIncomplete` to legacy/custom `renderCodeBlock` callbacks so the + deprecated compatibility renderer can use the same block-level boundary. + +### Compatibility and safety + +- Adapt the legacy `loadEcharts` and `resolveDataRef` callback shapes to the + shared renderer. +- Preserve the legacy Web Shell ref allowlist (`artifact://` and + `session-file://`) and normalized-path checks as the default registry policy + and in the deprecated adapter. +- Preserve legacy `markdown.renderCodeBlock` precedence when no explicit + `markdown.chart` override is supplied. Existing + `createEchartsFullDataRenderer({ loadEcharts, resolveDataRef })` integrations + therefore continue to use their host callbacks unchanged. +- Keep the model-facing skill separate from the renderer. Hosts install the + canonical `markdown-chart` skill; Web Shell renders its output by default but + does not inject or auto-load the skill. +- Use the shared package's strict JSON parser, option validation, bounded data + handling, data materialization, Chart/Data view, resize, and disposal. + +### Packaging + +- Add the public `@datafe-open/markdown-chart*` packages and `echarts` as Web + Shell runtime dependencies. +- Keep them external in the library build, matching the package's existing + dependency strategy. A standalone host that uses the registry's default + runtime loader can bundle ECharts lazily. + +## Files affected + +| Area | Files | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Shared adapter | `packages/web-shell/client/components/messages/MarkdownChartRenderer.tsx` | +| Removed duplicate implementation | `packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx`, its CSS module, and its implementation-heavy tests | +| Streaming metadata | `packages/web-shell/client/components/messages/Markdown.tsx`, `packages/web-shell/client/customization.tsx` | +| Public API and docs | `packages/web-shell/client/index.tsx`, `packages/web-shell/client/main.tsx`, `packages/web-shell/README.md` | +| Packaging | `packages/web-shell/package.json`, `packages/web-shell/vite.lib.config.ts`, root lockfile | +| Validation | focused Web Shell renderer and Markdown tests | + +## Scope boundaries + +- No change to the daemon, ACP protocol, transcript model, or model prompts. +- No automatic network or filesystem reads for chart data refs. +- No new chart renderer beyond ECharts. +- No PR, release, or package publication in this task. + +## Decisions + +- Use the published `@datafe-open/markdown-chart*` `0.1.12` packages, which + include localized labels, blockquote-aware streaming fence completion, and + restored tooltip safety invariants. +- Document only the canonical `markdown-chart` skill for new integrations. +- Preserve the old Web Shell exports as compatibility wrappers to avoid an + unrelated breaking package API change. +- Treat canonical `markdown-chart` as the integration protocol while retaining + legacy fenced blocks internally for existing hosts. + +## Open questions + +- A future breaking release can remove the deprecated + `EchartsFullDataBlock`-specific types after downstream usage is audited. +- Embedded consumers continue to decide whether to override the built-in + renderer and which data refs their host resolves. diff --git a/package-lock.json b/package-lock.json index f502a6b56de..973b0306495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13152,6 +13152,22 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/editions": { "version": "6.22.0", "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", @@ -21205,6 +21221,12 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -27505,6 +27527,21 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -30859,10 +30896,14 @@ "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.0", + "@datafe-open/markdown-chart": "^0.1.12", + "@datafe-open/markdown-chart-echarts": "^0.1.12", + "@datafe-open/markdown-chart-react": "^0.1.12", "@tanstack/react-virtual": "^3.13.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codemirror": "^6.0.0", + "echarts": "^6.0.0", "fzf": "^0.5.2", "katex": "^0.16.47", "lucide-react": "^1.24.0", @@ -30903,6 +30944,67 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "packages/web-shell/node_modules/@datafe-open/markdown-chart": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@datafe-open/markdown-chart/-/markdown-chart-0.1.12.tgz", + "integrity": "sha512-q/eS0se1h8jXoSPrGdKYiKxbGiG13mFoQYdA8RC41bFRQCJHCrQNk2UEhXWG0JJFabTopmMKMENUsDVWp0+Rvw==", + "license": "MIT" + }, + "packages/web-shell/node_modules/@datafe-open/markdown-chart-echarts": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@datafe-open/markdown-chart-echarts/-/markdown-chart-echarts-0.1.12.tgz", + "integrity": "sha512-/GzbYGC5ZYU0myaPlT+PLR2QLttf4W9CZ1oNmQJVGS0GNOqS2WTZeMBCvVkVqtXnT7e1We3RiG5MQsY4FhFTyA==", + "license": "MIT", + "dependencies": { + "@datafe-open/markdown-chart": "0.1.12", + "papaparse": "^5.5.3" + }, + "peerDependencies": { + "echarts": ">=5.5 <7" + } + }, + "packages/web-shell/node_modules/@datafe-open/markdown-chart-react": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@datafe-open/markdown-chart-react/-/markdown-chart-react-0.1.12.tgz", + "integrity": "sha512-JOAnOYgGBS6wVVUc5+DaV0TMiJUMms3YcOVTI2A1NIy80hPJyeWElFGTVML1L8nktU1Q1XQtsbxekSMuweJ7EA==", + "license": "MIT", + "dependencies": { + "@datafe-open/markdown-chart": "0.1.12", + "@datafe-open/markdown-chart-echarts": "0.1.12", + "react-markdown": "^10.1.0" + }, + "peerDependencies": { + "echarts": ">=5.5 <7", + "react": "^18.0.0 || ^19.0.0" + } + }, + "packages/web-shell/node_modules/@datafe-open/markdown-chart-react/node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "packages/web-shell/node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 44f1f191145..7bfcf84b267 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -266,47 +266,53 @@ const projection = projectChatRecordsToDaemonTranscript(records); 自定义内容仍使用内置的展开、收起行为,`expanded` 会随状态更新;文件夹行右侧的内置操作不会渲染。 未提供 `lockWorkspaceCwd` 时,该 renderer 不会执行。 -## 可选图表 Renderer +## Markdown 图表接入 -`WebShell` 支持宿主通过 `customization.markdown.renderCodeBlock` 接管特定 -fenced code block 的渲染。图表类场景可以注册内置的 -`echarts-fulldata` renderer: +`WebShell` 已内置 `markdown-chart` renderer 和 ECharts 运行时。宿主只需将 +[`markdown-chart` skill](https://github.com/datafe/markdown-chart/tree/main/skills/markdown-chart) +安装到 Qwen Code 的项目级或用户级 skills 目录;例如项目级安装结果为: + +```text +.qwen/skills/markdown-chart/SKILL.md +``` + +安装 skill 后按原方式使用 WebShell,不需要额外安装或导入 ECharts,也不需要传入 +图表配置: ```tsx -import { createEchartsFullDataRenderer } from '@qwen-code/web-shell'; + +``` - window.echarts, - resolveDataRef: async (ref, meta) => - loadControlledChartDataset(ref, meta), - }), - }} -/>; +skill 默认输出 `data.kind="inline"` 的 canonical `markdown-chart` block。 +WebShell 负责严格 JSON 校验、流式渲染、ECharts 生命周期以及 Chart/Data +切换;已经闭合的图表会立即渲染,只有末尾尚未闭合的 fence 显示 loading。 + +只有需要支持 skill 输出 `data.kind="ref"` 时,宿主才需要提供受控的 +`resolveDataRef`: + +```tsx +import { + createMarkdownChartRegistry, + WebShellWithProviders, +} from '@qwen-code/web-shell'; + +const chartRegistry = createMarkdownChartRegistry({ + resolveDataRef: async (ref, context) => + loadControlledChartDataset(ref, context), +}); +const markdown = { chart: { registry: chartRegistry } }; + +; ``` -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 -执行模型生成内容。 +`resolveDataRef` 是 ref 数据的唯一读取入口;WebShell 不会自行读取 URL 或本地 +路径。默认只接受规范化的 `artifact://` 和 `session-file://` ref,将 ref +规范化后交给 resolver,并在 30 秒后终止等待。`markdown` 及其中的 `chart` +对象应在图表挂载期间保持引用稳定。 +Chart/Data 控件、无数据提示和错误提示默认跟随 WebShell 语言;需要覆盖个别 +文案时可在稳定的 `chart` 对象上提供 `labels`。 +协议和数据格式见 +[`markdown-chart`](https://github.com/datafe/markdown-chart)。 ## 架构说明 diff --git a/packages/web-shell/client/components/messages/EchartsFullDataBlock.module.css b/packages/web-shell/client/components/messages/EchartsFullDataBlock.module.css deleted file mode 100644 index 94771185423..00000000000 --- a/packages/web-shell/client/components/messages/EchartsFullDataBlock.module.css +++ /dev/null @@ -1,189 +0,0 @@ -.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)); -} diff --git a/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx b/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx deleted file mode 100644 index b96b55a5bbb..00000000000 --- a/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx +++ /dev/null @@ -1,2988 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, type ReactNode } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { WebShellCustomizationProvider } from '../../customization'; -import { I18nProvider } from '../../i18n'; -import { ThemeProvider } from '../../themeContext'; -import { Markdown } from './Markdown'; -import { - ECHARTS_FULLDATA_SANITIZER_KEY_OVERLAP, - EchartsFullDataBlock, - createEchartsFullDataRenderer, - type EchartsFullDataOption, - type EchartsFullDataRefResolver, - type EchartsRuntime, - type EchartsRuntimeLoader, -} from './EchartsFullDataBlock'; - -( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } -).IS_REACT_ACT_ENVIRONMENT = true; - -const mounted: Array<{ root: Root; container: HTMLElement }> = []; - -async function mount(node: ReactNode): Promise<{ - container: HTMLElement; - root: Root; - rerender(next: ReactNode): Promise; -}> { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - await act(async () => { - root.render(node); - }); - mounted.push({ root, container }); - return { - container, - root, - rerender: async (next: ReactNode) => { - await act(async () => { - root.render(next); - }); - }, - }; -} - -async function render(node: ReactNode): Promise { - return (await mount({node})) - .container; -} - -async function flushChart(): Promise { - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -async function renderEchartsMarkdown({ - code, - fenceLanguage = 'echarts-fulldata', - loadEcharts, - resolveDataRef, - language = 'en', - source = 'assistant', -}: { - code: string; - fenceLanguage?: string; - loadEcharts?: EchartsRuntimeLoader; - resolveDataRef?: EchartsFullDataRefResolver; - language?: 'en' | 'zh-CN'; - source?: 'assistant' | 'thinking'; -}): Promise { - return render( - - - - - - - , - ); -} - -function getDataRows(container: HTMLElement): string[][] { - return Array.from(container.querySelectorAll('tbody tr')).map((row) => - Array.from(row.querySelectorAll('td')) - .slice(1) - .map((cell) => cell.textContent?.trim() ?? ''), - ); -} - -beforeEach(() => { - vi.spyOn(console, 'warn').mockImplementation(() => {}); -}); - -afterEach(async () => { - for (const { root, container } of mounted.splice(0)) { - await act(async () => { - root.unmount(); - }); - container.remove(); - } - vi.restoreAllMocks(); -}); - -describe('EchartsFullDataBlock', () => { - it('keeps top-level allowlist keys disjoint from nested denylist keys', () => { - expect(ECHARTS_FULLDATA_SANITIZER_KEY_OVERLAP).toEqual([]); - }); - - it('does not render echarts blocks from thinking markdown', async () => { - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { source: [[1]] }, - series: [{ type: 'bar' }], - }), - loadEcharts: () => runtime, - source: 'thinking', - }); - - expect(runtime.init).not.toHaveBeenCalled(); - expect( - container.querySelector('[data-testid="echarts-fulldata-rendered"]'), - ).toBeNull(); - expect(container.textContent).toContain('echarts-fulldata'); - }); - - it('renders echarts blocks with case-insensitive fence language matching', async () => { - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { source: [[1]] }, - series: [{ type: 'bar' }], - }), - fenceLanguage: 'ECharts-FullData', - loadEcharts: () => runtime, - }); - await flushChart(); - - expect(runtime.init).toHaveBeenCalledOnce(); - expect( - container.querySelector('[data-testid="echarts-fulldata-rendered"]'), - ).not.toBeNull(); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it('renders chart/data icon switching from dataset-backed options', async () => { - const option: EchartsFullDataOption = { - 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' } }], - }; - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - - expect(runtime.init).toHaveBeenCalledOnce(); - expect(runtime.init).toHaveBeenCalledWith(expect.any(HTMLElement), 'dark'); - expect(setOption).toHaveBeenCalledWith(expect.any(Object), { - notMerge: true, - }); - const renderedOption = setOption.mock.calls[0]?.[0] as - | EchartsFullDataOption - | undefined; - expect(renderedOption).toEqual( - expect.not.objectContaining({ title: expect.anything() }), - ); - expect(renderedOption).toEqual( - expect.objectContaining({ - backgroundColor: '#0d0d0d', - color: expect.arrayContaining(['#8AA0FF', '#60CCC5']), - grid: expect.objectContaining({ containLabel: true }), - }), - ); - expect(renderedOption?.xAxis).toEqual( - expect.objectContaining({ - axisLabel: expect.objectContaining({ color: '#9aa3b7' }), - }), - ); - expect(renderedOption?.series).toEqual([ - expect.objectContaining({ - barMaxWidth: 42, - itemStyle: expect.objectContaining({ borderRadius: [3, 3, 0, 0] }), - }), - ]); - expect(container.textContent).toContain('Weekly orders'); - expect(container.textContent).not.toContain('echarts-fulldata'); - expect( - container.querySelector('section[aria-label="Weekly orders"]'), - ).not.toBeNull(); - - const buttons = Array.from(container.querySelectorAll('button')); - expect(buttons.map((button) => button.textContent)).toEqual(['', '']); - expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([ - 'Show chart', - 'Show data', - ]); - - await act(async () => { - buttons[1]?.click(); - }); - - const headerTexts = Array.from(container.querySelectorAll('th')).map( - (cell) => cell.textContent?.trim() ?? '', - ); - expect(headerTexts[1]).toContain('day'); - expect(headerTexts[2]).toContain('orders'); - expect(container.textContent).toContain('Quick copy'); - expect( - container.querySelector('button[aria-label="Sort by orders"]'), - ).not.toBeNull(); - expect(getDataRows(container)).toEqual([ - ['Mon', '120'], - ['Tue', '200'], - ]); - - await act(async () => { - container - .querySelector('button[aria-label="Sort by orders"]') - ?.click(); - }); - await act(async () => { - container - .querySelector( - 'button[aria-label="Sort by orders, ascending"]', - ) - ?.click(); - }); - - expect(getDataRows(container)).toEqual([ - ['Tue', '200'], - ['Mon', '120'], - ]); - }); - - it('uses item tooltip trigger for item-only series', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - await render( - runtime} - />, - ); - await flushChart(); - - const renderedOption = setOption.mock.calls[0]?.[0] as { - tooltip?: { trigger?: string }; - }; - expect(renderedOption.tooltip?.trigger).toBe('item'); - }); - - it('styles multi-axis chart options with index-based defaults', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - await render( - runtime} - />, - ); - await flushChart(); - - const renderedOption = setOption.mock.calls[0]?.[0] as { - xAxis?: Array<{ axisTick?: { show?: boolean } }>; - yAxis?: Array<{ - nameTextStyle?: { align?: string }; - splitLine?: { show?: boolean }; - }>; - }; - expect(renderedOption.xAxis?.[0]?.axisTick?.show).toBe(true); - expect(renderedOption.xAxis?.[1]?.axisTick?.show).toBe(true); - expect(renderedOption.yAxis?.[0]?.splitLine?.show).toBe(true); - expect(renderedOption.yAxis?.[0]?.nameTextStyle?.align).toBe('left'); - expect(renderedOption.yAxis?.[1]?.splitLine?.show).toBe(false); - expect(renderedOption.yAxis?.[1]?.nameTextStyle?.align).toBe('right'); - }); - - it('keeps legacy ECharts option block bodies compatible', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - title: { text: 'Legacy option' }, - version: 'legacy-custom-metadata', - 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' } }], - }), - loadEcharts: () => runtime, - }); - await flushChart(); - - expect(runtime.init).toHaveBeenCalledOnce(); - expect(setOption.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - dataset: expect.objectContaining({ - dimensions: ['day', 'orders'], - source: [ - { day: 'Mon', orders: 120 }, - { day: 'Tue', orders: 200 }, - ], - }), - }), - ); - expect(container.textContent).toContain('Legacy option'); - expect(container.textContent).not.toContain('echarts-fulldata'); - }); - - it('renders inline envelope array rows in chart and data views', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'inline', - dimensions: ['day', 'orders'], - source: [ - ['Mon', 120], - ['Tue', 200], - ], - }, - option: { - title: { text: 'Inline envelope' }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }, - }), - loadEcharts: () => runtime, - }); - await flushChart(); - - expect(setOption.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - dataset: { - dimensions: ['day', 'orders'], - source: [ - ['Mon', 120], - ['Tue', 200], - ], - }, - }), - ); - - await act(async () => { - container.querySelectorAll('button')[1]?.click(); - }); - - expect(getDataRows(container)).toEqual([ - ['Mon', '120'], - ['Tue', '200'], - ]); - }); - - it('rejects inline envelope row length mismatches', async () => { - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'inline', - dimensions: ['day', 'orders'], - source: [['Mon', 120], ['Tue']], - }, - option: { - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }, - }), - }); - - expect(container.textContent).toContain( - 'Chart envelope data.source row 2 has 1 cells; expected 2.', - ); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it('rejects inline envelope cells that cannot be rendered safely', async () => { - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'inline', - dimensions: ['day', 'orders'], - source: [['Mon', { nested: 120 }]], - }, - option: { - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }, - }), - }); - - expect(container.textContent).toContain( - 'Chart envelope data.source row 1 cell 2 must be a string, number, boolean, or null.', - ); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it.each([ - { - name: 'unsupported envelope version', - envelope: { - version: 2, - data: { kind: 'inline', dimensions: ['day'], source: [['Mon']] }, - option: { series: [{ type: 'bar' }] }, - }, - error: 'Chart envelope version must be 1.', - }, - { - name: 'non-object envelope option', - envelope: { - version: 1, - data: { kind: 'inline', dimensions: ['day'], source: [['Mon']] }, - option: 'bar chart', - }, - error: 'Chart envelope option must be an object.', - }, - { - name: 'non-object envelope data', - envelope: { - version: 1, - data: 42, - option: { series: [{ type: 'bar' }] }, - }, - error: 'Chart envelope data must be an object.', - }, - { - name: 'unknown data kind', - envelope: { - version: 1, - data: { kind: 'remote' }, - option: { series: [{ type: 'bar' }] }, - }, - error: 'Chart envelope data.kind must be "inline" or "ref".', - }, - { - name: 'invalid ref dimensions', - envelope: { - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/orders', - format: 'json', - dimensions: [123], - }, - option: { series: [{ type: 'bar' }] }, - }, - error: 'Chart envelope data.dimensions must be a string array.', - }, - { - name: 'unsafe ref dimensions', - envelope: { - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/orders', - format: 'json', - dimensions: ['day', 'javascript:alert(1)'], - }, - option: { series: [{ type: 'bar' }] }, - }, - error: - 'Chart envelope data.dimensions contains an unsafe dimension name.', - }, - { - name: 'invalid ref format', - envelope: { - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/orders', - format: 'xml', - dimensions: ['day'], - }, - option: { series: [{ type: 'bar' }] }, - }, - error: 'Chart envelope data.format must be "csv" or "json".', - }, - { - name: 'malformed percent encoding in ref', - envelope: { - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/%ZZorders', - format: 'json', - dimensions: ['day'], - }, - option: { series: [{ type: 'bar' }] }, - }, - error: 'Chart envelope data.ref path is malformed.', - }, - { - name: 'invalid inline dimensions', - envelope: { - version: 1, - data: { kind: 'inline', dimensions: 'day', source: [['Mon']] }, - option: { series: [{ type: 'bar' }] }, - }, - error: 'Chart envelope data.dimensions must be a string array.', - }, - { - name: 'invalid inline source', - envelope: { - version: 1, - data: { kind: 'inline', dimensions: ['day'], source: 'Mon' }, - option: { series: [{ type: 'bar' }] }, - }, - error: 'Chart envelope data.source must be an array of rows.', - }, - ])( - 'rejects invalid full-data envelopes: $name', - async ({ envelope, error }) => { - const code = JSON.stringify(envelope); - const container = await renderEchartsMarkdown({ - code, - }); - - expect(container.textContent).toContain(error); - expect(container.querySelector('pre code')).toBeNull(); - expect(console.warn).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata parse failed: %s (code length=%d)', - error, - code.length, - ); - }, - ); - - it('rejects legacy array-row dimension mismatches', async () => { - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - dimensions: ['day', 'orders'], - source: [['Mon', 120], ['Tue']], - }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }), - }); - - expect(container.textContent).toContain( - 'Chart data row 2 has 1 cells; expected 2.', - ); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it('rejects legacy dataset cells that cannot be rendered safely', async () => { - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - dimensions: ['day', 'orders'], - source: [['Mon', { nested: 120 }]], - }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }), - }); - - expect(container.textContent).toContain( - 'Chart data row 1 cell 2 must be a string, number, boolean, or null.', - ); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it('rejects unsafe legacy dataset dimensions', async () => { - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - dimensions: ['day', 'constructor'], - source: [{ day: 'Mon', orders: 120 }], - }, - series: [{ type: 'bar', encode: { x: 'day', y: 'constructor' } }], - }), - }); - - expect(container.textContent).toContain( - 'Chart envelope data.dimensions contains an unsafe dimension name.', - ); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it('resolves ref envelope data through the host resolver', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const resolveDataRef = vi.fn(async () => ({ - dimensions: ['day', 'orders'], - source: [ - ['Mon', 120], - ['Tue', 200], - ], - })); - - await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'ARTIFACT://chart-data/%6Frders', - format: 'csv', - dimensions: ['day', 'orders'], - }, - option: { - title: { text: 'Ref envelope' }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], - }, - }), - loadEcharts: () => runtime, - resolveDataRef, - }); - await flushChart(); - - expect(resolveDataRef).toHaveBeenCalledWith( - 'artifact://chart-data/orders', - { - format: 'csv', - dimensions: ['day', 'orders'], - }, - ); - expect(setOption.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - dataset: { - dimensions: ['day', 'orders'], - source: [ - ['Mon', 120], - ['Tue', 200], - ], - }, - }), - ); - }); - - it('does not refetch resolved ref data when streaming toggles with unchanged code', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const resolveDataRef = vi.fn(async () => ({ - dimensions: ['day', 'orders'], - source: [['Mon', 120]], - })); - const renderer = createEchartsFullDataRenderer({ - loadEcharts: () => runtime, - resolveDataRef, - }); - const content = `\`\`\`echarts-fulldata\n${JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/orders', - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }, - })}\n\`\`\``; - const tree = (isStreaming: boolean) => ( - - - - - - - - ); - - const { rerender } = await mount(tree(false)); - await flushChart(); - expect(resolveDataRef).toHaveBeenCalledOnce(); - expect(setOption).toHaveBeenCalledOnce(); - - await rerender(tree(true)); - await flushChart(); - expect(resolveDataRef).toHaveBeenCalledOnce(); - - await rerender(tree(false)); - await flushChart(); - expect(resolveDataRef).toHaveBeenCalledOnce(); - expect(setOption).toHaveBeenCalledTimes(2); - }); - - it('reports ref resolver rejections inside the chart card', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/missing', - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], - }, - }), - loadEcharts: () => runtime, - resolveDataRef: () => Promise.reject(new Error('missing artifact')), - }); - await flushChart(); - - expect(container.textContent).toContain( - 'Chart data reference could not be resolved.', - ); - expect(container.textContent).not.toContain('missing artifact'); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata data-ref resolution failed:', - 'artifact://chart-data/missing', - expect.any(Error), - ); - expect(container.querySelector('pre code')).toBeNull(); - expect(setOption).not.toHaveBeenCalled(); - }); - - it('reports non-error ref resolver rejections with the generic message', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/missing', - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], - }, - }), - loadEcharts: () => runtime, - resolveDataRef: () => Promise.reject('missing artifact'), - }); - await flushChart(); - - expect(container.textContent).toContain( - 'Chart data reference could not be resolved.', - ); - expect(container.textContent).not.toContain('missing artifact'); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata data-ref resolution failed:', - 'artifact://chart-data/missing', - 'missing artifact', - ); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it('reports invalid ref resolver data with a resolver-specific diagnostic', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/invalid', - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], - }, - }), - loadEcharts: () => runtime, - resolveDataRef: () => - null as unknown as ReturnType, - }); - await flushChart(); - - expect(container.textContent).toContain( - 'Chart data resolver returned an invalid dataset.', - ); - expect(container.textContent).not.toContain( - 'Chart envelope data.dimensions must be a string array.', - ); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata resolver returned invalid dataset:', - 'artifact://chart-data/invalid', - null, - ); - expect(setOption).not.toHaveBeenCalled(); - }); - - it('reports ref resolver timeouts inside the chart card', async () => { - vi.useFakeTimers(); - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - try { - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/slow', - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], - }, - }), - loadEcharts: () => runtime, - resolveDataRef: () => new Promise(() => {}), - }); - - await act(async () => { - vi.advanceTimersByTime(30_000); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain( - 'Chart data reference could not be resolved.', - ); - expect(container.textContent).not.toContain( - 'Data reference resolution timed out.', - ); - expect(console.warn).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata data-ref resolution timed out after %dms (ref=%s, format=%s)', - 30_000, - 'artifact://chart-data/slow', - 'json', - ); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata data-ref resolution failed:', - 'artifact://chart-data/slow', - expect.any(Error), - ); - expect(container.querySelector('pre code')).toBeNull(); - expect(setOption).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('ignores stale ref resolutions after the chart code changes', async () => { - let resolveFirst: (value: { - dimensions: string[]; - source: Array>; - }) => void = () => {}; - let resolveSecond: (value: { - dimensions: string[]; - source: Array>; - }) => void = () => {}; - const resolveDataRef = vi.fn( - (ref: string) => - new Promise<{ - dimensions: string[]; - source: Array>; - }>((resolve) => { - if (ref.endsWith('/first')) { - resolveFirst = resolve; - } else { - resolveSecond = resolve; - } - }), - ); - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const renderer = createEchartsFullDataRenderer({ - loadEcharts: () => runtime, - resolveDataRef, - }); - const contentFor = (ref: string) => - `\`\`\`echarts-fulldata\n${JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref, - format: 'csv', - dimensions: ['day', 'orders'], - }, - option: { - title: { text: 'Ref race' }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], - }, - })}\n\`\`\``; - const tree = (ref: string) => ( - - - - - - - - ); - - const { rerender } = await mount(tree('artifact://chart-data/first')); - await flushChart(); - await rerender(tree('artifact://chart-data/second')); - await flushChart(); - - await act(async () => { - resolveSecond({ - dimensions: ['day', 'orders'], - source: [['Tue', 200]], - }); - await Promise.resolve(); - await Promise.resolve(); - }); - await flushChart(); - - expect(setOption).toHaveBeenCalledTimes(1); - expect(setOption.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - dataset: { - dimensions: ['day', 'orders'], - source: [['Tue', 200]], - }, - }), - ); - - await act(async () => { - resolveFirst({ - dimensions: ['day', 'orders'], - source: [['Mon', 120]], - }); - await Promise.resolve(); - await Promise.resolve(); - }); - await flushChart(); - - expect(setOption).toHaveBeenCalledTimes(1); - }); - - it('does not re-resolve ref data when the resolver prop identity changes', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const resolveDataRef = vi.fn(async () => ({ - dimensions: ['day', 'orders'], - source: [['Mon', 120]], - })); - const content = `\`\`\`echarts-fulldata\n${JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/orders', - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - title: { text: 'Stable resolver' }, - series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], - }, - })}\n\`\`\``; - const tree = (nonce: number) => ( - - - runtime, - resolveDataRef: (ref, meta) => { - void nonce; - return resolveDataRef(ref, meta); - }, - }), - }, - }} - > - - - - - ); - - const { rerender } = await mount(tree(1)); - await flushChart(); - await rerender(tree(2)); - await flushChart(); - - expect(resolveDataRef).toHaveBeenCalledOnce(); - expect(setOption).toHaveBeenCalledOnce(); - }); - - it('reports missing and unsupported ref resolver states without showing raw code', async () => { - const missingResolver = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/orders', - format: 'csv', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'bar' }], - }, - }), - }); - expect(missingResolver.textContent).toContain( - 'Chart data reference resolver is unavailable.', - ); - expect(missingResolver.querySelector('pre code')).toBeNull(); - - const resolveDataRef = vi.fn(); - const unsupportedScheme = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'https://example.test/data.json', - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'bar' }], - }, - }), - resolveDataRef, - }); - expect(resolveDataRef).not.toHaveBeenCalled(); - expect(unsupportedScheme.textContent).toContain( - 'Chart envelope data.ref must use artifact:// or session-file://.', - ); - expect(unsupportedScheme.querySelector('pre code')).toBeNull(); - }); - - it('validates ref envelopes before calling the host resolver', async () => { - const resolveDataRef = vi.fn(); - const invalidRefs = [ - { - ref: 'artifact://', - message: 'Chart envelope data.ref path must be non-empty.', - }, - { - ref: 'session-file://../../secret', - message: - 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', - }, - { - ref: 'session-file://%2e%2e/secret', - message: - 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', - }, - { - ref: 'session-file://.', - message: - 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', - }, - { - ref: 'artifact://chart-data/./orders', - message: - 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', - }, - { - ref: 'session-file://C:/Users/alice/secret.csv', - message: - 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', - }, - { - ref: 'session-file://C:../secret.csv', - message: - 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', - }, - { - ref: 'artifact://chart-data/orders?token=1', - message: - 'Chart envelope data.ref path must not include query, hash, or backslash characters.', - }, - { - ref: 'artifact://chart-data/%252e%252e/orders', - message: - 'Chart envelope data.ref path must not include query, hash, whitespace, control, backslash, or double-encoded percent characters.', - }, - { - ref: 'artifact://chart-data/\u0000orders', - message: - 'Chart envelope data.ref must not contain whitespace or control characters.', - }, - ]; - - for (const { ref, message } of invalidRefs) { - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref, - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'bar' }], - }, - }), - resolveDataRef, - }); - - expect(container.textContent).toContain(message); - expect(container.querySelector('pre code')).toBeNull(); - } - expect(resolveDataRef).not.toHaveBeenCalled(); - }); - - it('rejects resolved ref data over the dataset limits', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'session-file://chart-data/orders', - format: 'csv', - dimensions: ['period'], - }, - option: { - series: [{ type: 'bar' }], - }, - }), - loadEcharts: () => runtime, - resolveDataRef: () => ({ - dimensions: ['period'], - source: Array.from({ length: 2001 }, (_, index) => [`P${index}`]), - }), - }); - await flushChart(); - - expect(container.textContent).toContain( - 'Chart data has too many rows. Maximum supported rows: 2000.', - ); - expect(setOption).not.toHaveBeenCalled(); - }); - - it('normalizes {c} label templates for object-row datasets', async () => { - const longDimension = 'x'.repeat(300); - const longFormatter = '{c}'.repeat(1_400); - const option: EchartsFullDataOption = { - title: { text: 'Monthly metrics' }, - dataset: { - dimensions: ['month', 'convRate', 'aov', 'cost$', longDimension], - source: [ - { - month: 'Jan', - convRate: 3.1, - aov: 186, - cost$: 42, - [longDimension]: 1, - }, - { - month: 'Feb', - convRate: 3.4, - aov: 192, - cost$: 43, - 'profit|margin': 0.34, - 'close}rate': 0.78, - [longDimension]: 2, - }, - ], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [ - { - type: 'line', - encode: { x: 'month', y: 'convRate' }, - label: { show: true, formatter: '{c:.2f}%' }, - }, - { - type: 'line', - encode: { x: 'month', y: 'aov' }, - label: { show: true, formatter: '¥{c}' }, - }, - { - type: 'line', - encode: { x: 'month', y: 'cost$' }, - label: { show: true, formatter: 'Cost {c:.1f}' }, - }, - { - type: 'line', - encode: { x: 'month', y: 'missing' }, - label: { show: true, formatter: '{c}' }, - }, - { - type: 'line', - encode: { x: 'month', y: longDimension }, - label: { show: true, formatter: '{c}' }, - }, - { - type: 'line', - encode: { x: 'month', y: 'profit|margin' }, - label: { show: true, formatter: '{c}%' }, - }, - { - type: 'line', - encode: { x: 'month', y: 'close}rate' }, - label: { show: true, formatter: '{c:.1f}' }, - }, - { - type: 'line', - encode: { x: 'month', y: 'convRate' }, - label: { show: true, formatter: longFormatter }, - }, - ], - }; - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - await render( - runtime} - />, - ); - await flushChart(); - - const renderedOption = setOption.mock.calls[0]?.[0] as { - series?: Array<{ label?: { formatter?: string } }>; - }; - expect(renderedOption.series?.[0]?.label?.formatter).toBe( - '{@convRate|.2f}%', - ); - expect(renderedOption.series?.[1]?.label?.formatter).toBe('¥{@aov}'); - expect(renderedOption.series?.[2]?.label?.formatter).toBe( - 'Cost {@cost$|.1f}', - ); - expect(renderedOption.series?.[3]?.label?.formatter).toBe('{c}'); - expect(renderedOption.series?.[4]?.label?.formatter).toBe('{c}'); - expect(renderedOption.series?.[5]?.label?.formatter).toBe('{c}%'); - expect(renderedOption.series?.[6]?.label?.formatter).toBe('{c:.1f}'); - expect(renderedOption.series?.[7]?.label?.formatter).toBe(longFormatter); - }); - - it('normalizes {c} label templates for inline array-row envelopes', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - await renderEchartsMarkdown({ - code: JSON.stringify({ - version: 1, - data: { - kind: 'inline', - dimensions: ['day', 'orders'], - source: [['Mon', 120]], - }, - option: { - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [ - { - type: 'bar', - encode: { x: 'day', y: 'orders' }, - label: { show: true, formatter: '{c:.1f}' }, - }, - ], - }, - }), - loadEcharts: () => runtime, - }); - await flushChart(); - - const renderedOption = setOption.mock.calls[0]?.[0] as { - series?: Array<{ label?: { formatter?: string } }>; - }; - expect(renderedOption.series?.[0]?.label?.formatter).toBe('{@orders|.1f}'); - }); - - it('renders array-row datasets with named dimensions in the data view', async () => { - const option: EchartsFullDataOption = { - title: { text: 'Weekly orders' }, - dataset: { - dimensions: ['day', 'orders'], - source: [ - ['Mon', 120], - ['Tue', 200], - ], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - await act(async () => { - container.querySelectorAll('button')[1]?.click(); - }); - - expect(getDataRows(container)).toEqual([ - ['Mon', '120'], - ['Tue', '200'], - ]); - }); - - it('coerces numeric dataset dimensions for chart and data views', async () => { - const option: EchartsFullDataOption = { - title: { text: 'Yearly orders' }, - dataset: { - dimensions: ['month', 2023, 2024] as unknown as string[], - source: [['Jan', 100, 200]], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [ - { - type: 'bar', - encode: { x: 'month', y: 2023 }, - label: { show: true, formatter: '{c}' }, - }, - ], - }; - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - - const renderedOption = setOption.mock.calls[0]?.[0] as { - dataset?: { dimensions?: string[] }; - series?: Array<{ label?: { formatter?: string } }>; - }; - expect(renderedOption.dataset?.dimensions).toEqual([ - 'month', - '2023', - '2024', - ]); - expect(renderedOption.series?.[0]?.label?.formatter).toBe('{@2023}'); - - await act(async () => { - container.querySelectorAll('button')[1]?.click(); - }); - - const headerTexts = Array.from(container.querySelectorAll('th')).map( - (cell) => cell.textContent?.trim() ?? '', - ); - expect(headerTexts.some((text) => text.includes('month'))).toBe(true); - expect(headerTexts.some((text) => text.includes('2023'))).toBe(true); - expect(headerTexts.some((text) => text.includes('2024'))).toBe(true); - expect(getDataRows(container)).toEqual([['Jan', '100', '200']]); - }); - - it('uses the sanitized dataset consistently for chart and data views', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const option: EchartsFullDataOption = { - title: { text: 'Sanitized rows' }, - dataset: { - dimensions: ['label', 'url'], - source: [{ label: 'x runtime} - />, - ); - await flushChart(); - - const renderedOption = setOption.mock.calls[0]?.[0] as { - dataset?: { source?: Array> }; - }; - expect(renderedOption.dataset?.source?.[0]).toEqual({ - label: 'x { - container.querySelectorAll('button')[1]?.click(); - }); - - expect(getDataRows(container)).toEqual([['x { - const option: EchartsFullDataOption = { - title: { text: 'Weekly orders' }, - dataset: { - dimensions: [null, {}, { name: 'orders' }] as unknown as string[], - source: [['Mon', 120]], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 0, y: 1 } }], - }; - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - await act(async () => { - container.querySelectorAll('button')[1]?.click(); - }); - - const headerTexts = Array.from(container.querySelectorAll('th')).map( - (cell) => cell.textContent?.trim() ?? '', - ); - expect(headerTexts[1]).toContain('0'); - expect(headerTexts[2]).toContain('orders'); - expect(getDataRows(container)).toEqual([['Mon', '120']]); - }); - - it('keeps the parsed option stable across parent rerenders with unchanged code', async () => { - const code = JSON.stringify({ - title: { text: 'Weekly orders' }, - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }); - const dispose = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose, - })), - }; - const renderer = createEchartsFullDataRenderer({ - loadEcharts: () => runtime, - }); - const content = `\`\`\`echarts-fulldata\n${code}\n\`\`\``; - const tree = (nonce: number) => ( -
- - - - - -
- ); - - const { rerender } = await mount(tree(1)); - await flushChart(); - await rerender(tree(2)); - await flushChart(); - - expect(runtime.init).toHaveBeenCalledTimes(1); - expect(dispose).not.toHaveBeenCalled(); - }); - - it('does not recompute chart options when toggling data view', async () => { - const plainOption: EchartsFullDataOption = { - title: { text: 'Weekly orders' }, - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - let cloneCount = 0; - const option = { - ...plainOption, - toJSON: () => { - cloneCount += 1; - return plainOption; - }, - } as EchartsFullDataOption & { toJSON: () => EchartsFullDataOption }; - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - - await act(async () => { - container.querySelectorAll('button')[1]?.click(); - }); - await act(async () => { - container.querySelectorAll('button')[0]?.click(); - }); - await flushChart(); - - expect(cloneCount).toBe(1); - expect(runtime.init).toHaveBeenCalledTimes(2); - expect(setOption).toHaveBeenCalledTimes(2); - }); - - it('does not recreate the chart when the loader prop identity changes', async () => { - const option: EchartsFullDataOption = { - title: { text: 'Weekly orders' }, - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - const dispose = vi.fn(); - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose, - })), - }; - const tree = (nonce: number) => ( - -
- runtime} - /> -
-
- ); - - const { rerender } = await mount(tree(1)); - await flushChart(); - await rerender(tree(2)); - await flushChart(); - - expect(runtime.init).toHaveBeenCalledOnce(); - expect(setOption).toHaveBeenCalledOnce(); - expect(dispose).not.toHaveBeenCalled(); - }); - - it('reinitializes the chart when the theme changes', async () => { - const plainOption: EchartsFullDataOption = { - title: { text: 'Weekly orders' }, - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - let cloneCount = 0; - const option = { - ...plainOption, - toJSON: () => { - cloneCount += 1; - return plainOption; - }, - } as EchartsFullDataOption & { toJSON: () => EchartsFullDataOption }; - const dispose = vi.fn(); - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose, - })), - }; - const tree = (theme: 'dark' | 'light') => ( - - runtime} - /> - - ); - - const { rerender } = await mount(tree('dark')); - await flushChart(); - await rerender(tree('light')); - await flushChart(); - - expect(cloneCount).toBe(1); - expect(runtime.init).toHaveBeenCalledTimes(2); - expect(runtime.init).toHaveBeenNthCalledWith( - 1, - expect.any(HTMLElement), - 'dark', - ); - expect(runtime.init).toHaveBeenNthCalledWith( - 2, - expect.any(HTMLElement), - 'light', - ); - expect(dispose).toHaveBeenCalledOnce(); - expect(setOption).toHaveBeenCalledTimes(2); - expect(setOption.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ backgroundColor: '#0d0d0d' }), - ); - expect(setOption.mock.calls[1]?.[0]).toEqual( - expect.objectContaining({ backgroundColor: '#ffffff' }), - ); - }); - - it('shows loading while the chart runtime loader is pending', async () => { - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - let resolveRuntime: (runtime: EchartsRuntime) => void = () => {}; - const runtimePromise = new Promise((resolve) => { - resolveRuntime = resolve; - }); - - const container = await render( - runtimePromise} - />, - ); - - expect(container.querySelector('[role="status"]')).not.toBeNull(); - - await act(async () => { - resolveRuntime(runtime); - await runtimePromise; - await Promise.resolve(); - }); - - expect(container.querySelector('[role="status"]')).toBeNull(); - expect(runtime.init).toHaveBeenCalledOnce(); - }); - - it('reports chart runtime loader timeouts instead of spinning forever', async () => { - vi.useFakeTimers(); - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - - try { - const container = await render( - new Promise(() => {})} - />, - ); - - expect(container.querySelector('[role="status"]')).not.toBeNull(); - - await act(async () => { - vi.advanceTimersByTime(30_000); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('Chart runtime load timed out.'); - expect(console.warn).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata runtime load timed out after %dms', - 30_000, - ); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata render failed:', - expect.any(Error), - ); - expect(container.querySelector('[role="status"]')).toBeNull(); - } finally { - vi.useRealTimers(); - } - }); - - it('observes chart container resize after initialization', async () => { - const originalResizeObserver = globalThis.ResizeObserver; - const addEventListener = vi.spyOn(window, 'addEventListener'); - const observe = vi.fn(); - const disconnect = vi.fn(); - class ResizeObserverStub { - observe = observe; - disconnect = disconnect; - } - globalThis.ResizeObserver = - ResizeObserverStub as unknown as typeof ResizeObserver; - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - try { - await render( - runtime} - />, - ); - await flushChart(); - - expect(observe).toHaveBeenCalledWith(expect.any(HTMLElement)); - expect( - addEventListener.mock.calls.filter(([type]) => type === 'resize'), - ).toHaveLength(0); - } finally { - globalThis.ResizeObserver = originalResizeObserver; - } - }); - - it('falls back to window resize listener when ResizeObserver is unavailable', async () => { - const originalResizeObserver = globalThis.ResizeObserver; - const addEventListener = vi.spyOn(window, 'addEventListener'); - const removeEventListener = vi.spyOn(window, 'removeEventListener'); - globalThis.ResizeObserver = undefined as unknown as typeof ResizeObserver; - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const tree = (nextOption?: EchartsFullDataOption) => ( - - runtime} - /> - - ); - - try { - const { rerender } = await mount(tree(option)); - await flushChart(); - - expect( - addEventListener.mock.calls.filter(([type]) => type === 'resize'), - ).toHaveLength(1); - - await rerender(tree(undefined)); - - expect( - removeEventListener.mock.calls.filter(([type]) => type === 'resize'), - ).toHaveLength(1); - } finally { - globalThis.ResizeObserver = originalResizeObserver; - } - }); - - it('reports synchronous loader failures inside the chart card', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - - const container = await render( - { - throw new Error('loader failed'); - }} - />, - ); - await flushChart(); - - expect(container.textContent).toContain('loader failed'); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata render failed:', - expect.any(Error), - ); - }); - - it('reports async loader rejections inside the chart card', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - - const container = await render( - Promise.reject(new Error('async loader failed'))} - />, - ); - await flushChart(); - - expect(container.textContent).toContain('async loader failed'); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata render failed:', - expect.any(Error), - ); - }); - - it('disposes a partially initialized chart when setOption fails', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const dispose = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(() => { - throw new Error('setOption failed'); - }), - resize: vi.fn(), - dispose, - })), - }; - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - - expect(container.textContent).toContain('setOption failed'); - expect(dispose).toHaveBeenCalledOnce(); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata render failed:', - expect.any(Error), - ); - }); - - it('reports chart option sanitization failures inside the chart card', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - - expect(container.textContent).toContain('BigInt'); - expect(setOption).not.toHaveBeenCalled(); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata render failed:', - expect.any(TypeError), - ); - }); - - it('recovers from chart errors after option changes', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const dispose = vi.fn(); - const setOption = vi - .fn() - .mockImplementationOnce(() => { - throw new Error('setOption failed'); - }) - .mockImplementation(() => {}); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose, - })), - }; - const baseOption: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - const nextOption: EchartsFullDataOption = { - ...baseOption, - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Tue', orders: 200 }], - }, - }; - - const { container, root } = await mount( - - runtime} - /> - , - ); - await flushChart(); - - expect(container.textContent).toContain('setOption failed'); - expect( - container.querySelector('[data-testid="echarts-fulldata-chart"]'), - ).not.toBeNull(); - - await act(async () => { - root.render( - - runtime} - /> - , - ); - }); - await flushChart(); - - expect(container.textContent).not.toContain('setOption failed'); - expect(runtime.init).toHaveBeenCalledTimes(2); - expect(setOption).toHaveBeenCalledTimes(2); - expect(dispose).toHaveBeenCalledOnce(); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata render failed:', - expect.any(Error), - ); - }); - - it('clears stale chart errors while ref data is pending after option changes', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - let resolveRef: (value: { - dimensions: string[]; - source: Array>; - }) => void = () => {}; - const resolveDataRef = vi.fn( - () => - new Promise((resolve) => { - resolveRef = resolve; - }), - ); - const setOption = vi - .fn() - .mockImplementationOnce(() => { - throw new Error('setOption failed'); - }) - .mockImplementation(() => {}); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const renderer = createEchartsFullDataRenderer({ - loadEcharts: () => runtime, - resolveDataRef, - }); - const tree = (content: string) => ( - - - - - - - - ); - const directContent = `\`\`\`echarts-fulldata\n${JSON.stringify({ - dataset: { - dimensions: ['day', 'orders'], - source: [['Mon', 120]], - }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - })}\n\`\`\``; - const refContent = `\`\`\`echarts-fulldata\n${JSON.stringify({ - version: 1, - data: { - kind: 'ref', - ref: 'artifact://chart-data/orders', - format: 'json', - dimensions: ['day', 'orders'], - }, - option: { - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }, - })}\n\`\`\``; - - const { container, rerender } = await mount(tree(directContent)); - await flushChart(); - expect(container.textContent).toContain('setOption failed'); - - await rerender(tree(refContent)); - await flushChart(); - - expect(resolveDataRef).toHaveBeenCalledOnce(); - expect(container.textContent).not.toContain('setOption failed'); - expect( - container.querySelector('[role="status"]')?.getAttribute('aria-label'), - ).toBe('Rendering chart'); - - await act(async () => { - resolveRef({ - dimensions: ['day', 'orders'], - source: [['Tue', 200]], - }); - await Promise.resolve(); - await Promise.resolve(); - }); - await flushChart(); - - expect(setOption).toHaveBeenCalledTimes(2); - }); - - it('sanitizes unsafe chart option fields before calling ECharts', async () => { - const unsafeSeriesEntry: Record = { - type: 'line', - cursor: 'url(https://example.test/ping), auto', - datasetIndex: 1, - data: [120, 999], - href: 'javascript:alert(1)', - id: 'data:text/html,', - name: 'blob:https://example.test/marker', - encode: { x: 'day', y: 'orders' }, - itemStyle: { - image: 'file:///tmp/marker.png', - }, - markLine: { - data: [ - { - yAxis: 150, - label: { formatter: 'Goal ' }, - lineStyle: { color: 'javascript:alert(1)' }, - }, - ], - }, - markArea: { - data: [[{ xAxis: 'Mon' }, { xAxis: 'Tue' }]], - itemStyle: { color: 'javascript:alert(1)' }, - }, - renderItem: 'javascript:alert(1)', - src: 'https://example.test/marker.png', - stack: '//example.test/stack', - symbol: 'image://https://example.test/marker.png', - tooltip: { - appendToBody: true, - enterable: true, - formatter: '', - renderMode: 'html', - }, - }; - Object.defineProperty(unsafeSeriesEntry, '__proto__', { - value: { polluted: true }, - enumerable: true, - }); - const unsafeRow: Record = { - day: 'javascript:alert(1)', - orders: 120, - }; - Object.defineProperty(unsafeRow, '__proto__', { - value: null, - enumerable: true, - }); - const businessTextSeriesEntry = { - type: 'line', - name: 'Latency ', - encode: { x: 'day', y: 'orders' }, - label: { - formatter: 'Results ', - }, - }; - - const option: EchartsFullDataOption = { - color: ['#ff0000', 'javascript:alert(1)', '#0000ff'], - dataset: [ - { - dimensions: [ - 'day', - 'orders', - 'constructor', - 'javascript:alert(1)', - { name: 'region', displayName: '' }, - { name: '' }, - 2024, - ] as unknown as string[], - source: [unsafeRow], - transform: { type: 'filter' }, - }, - { - dimensions: ['day', 'orders'], - source: [{ day: 'Tue', orders: 999 }], - }, - ] as EchartsFullDataOption['dataset'], - graphic: { type: 'text', style: { text: '' } }, - legend: { - data: ['Mon', 'Tue'], - }, - tooltip: { - formatter: '', - extraCssText: 'background-image:url(https://example.test/x.png)', - }, - xAxis: { - type: 'category', - data: ['Mon', 'Tue'], - name: '', - }, - yAxis: { type: 'value' }, - series: [unsafeSeriesEntry, businessTextSeriesEntry], - }; - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - await render( - runtime} - />, - ); - await flushChart(); - - const renderedOption = setOption.mock.calls[0]?.[0] as { - color?: unknown[]; - dataset?: Array<{ - dimensions?: unknown[]; - source?: Array>; - transform?: unknown; - }>; - graphic?: unknown; - legend?: { data?: unknown }; - tooltip?: { - formatter?: unknown; - extraCssText?: string; - renderMode?: string; - enterable?: boolean; - }; - xAxis?: { data?: unknown; name?: unknown }; - series?: Array<{ - cursor?: unknown; - data?: unknown; - datasetIndex?: unknown; - href?: unknown; - id?: unknown; - itemStyle?: { image?: unknown }; - label?: { formatter?: string }; - markArea?: { - data?: Array>; - itemStyle?: { color?: unknown }; - }; - markLine?: { - data?: Array<{ - yAxis?: number; - label?: { formatter?: string }; - lineStyle?: { color?: unknown }; - }>; - }; - name?: string; - polluted?: boolean; - renderItem?: unknown; - src?: unknown; - stack?: unknown; - symbol?: string; - tooltip?: { - appendToBody?: boolean; - confine?: boolean; - enterable?: boolean; - formatter?: unknown; - renderMode?: string; - }; - }>; - }; - expect(renderedOption.color).toEqual(['#ff0000', null, '#0000ff']); - expect(renderedOption.graphic).toBeUndefined(); - expect(renderedOption.dataset).toHaveLength(2); - expect(renderedOption.dataset?.[0]?.transform).toBeUndefined(); - expect(renderedOption.dataset?.[0]?.dimensions).toEqual([ - 'day', - 'orders', - {}, - {}, - { name: 'region' }, - {}, - '2024', - ]); - expect(renderedOption.dataset?.[0]?.source?.[0]?.day).toBe(''); - expect(renderedOption.dataset?.[0]?.source).toHaveLength(1); - expect( - Object.getPrototypeOf(renderedOption.dataset?.[0]?.source?.[0]), - ).toBe(Object.prototype); - expect( - Object.prototype.hasOwnProperty.call( - renderedOption.dataset?.[0]?.source?.[0], - '__proto__', - ), - ).toBe(false); - expect(renderedOption.dataset?.[1]?.source?.[0]?.day).toBe('Tue'); - expect(renderedOption.legend?.data).toBeUndefined(); - expect(renderedOption.tooltip?.formatter).toBeUndefined(); - expect(renderedOption.tooltip?.extraCssText).not.toContain('https://'); - expect(renderedOption.tooltip).toEqual( - expect.objectContaining({ - enterable: false, - renderMode: 'richText', - }), - ); - expect(renderedOption.xAxis?.data).toBeUndefined(); - expect(renderedOption.xAxis?.name).toBeUndefined(); - expect(renderedOption.series?.[0]?.cursor).toBeUndefined(); - expect(renderedOption.series?.[0]?.data).toBeUndefined(); - expect(renderedOption.series?.[0]?.datasetIndex).toBe(1); - expect(renderedOption.series?.[0]?.href).toBeUndefined(); - expect(renderedOption.series?.[0]?.id).toBeUndefined(); - expect(renderedOption.series?.[0]?.itemStyle?.image).toBeUndefined(); - expect(renderedOption.series?.[0]?.markArea?.data?.[0]?.[0]?.xAxis).toBe( - 'Mon', - ); - expect( - renderedOption.series?.[0]?.markArea?.itemStyle?.color, - ).toBeUndefined(); - expect(renderedOption.series?.[0]?.markLine?.data?.[0]?.yAxis).toBe(150); - expect( - renderedOption.series?.[0]?.markLine?.data?.[0]?.label?.formatter, - ).toBe('Goal '); - expect( - renderedOption.series?.[0]?.markLine?.data?.[0]?.lineStyle?.color, - ).toBeUndefined(); - expect(renderedOption.series?.[0]?.name).toBeUndefined(); - expect(renderedOption.series?.[0]?.polluted).toBeUndefined(); - expect(Object.getPrototypeOf(renderedOption.series?.[0])).toBe( - Object.prototype, - ); - expect(renderedOption.series?.[0]?.renderItem).toBeUndefined(); - expect(renderedOption.series?.[0]?.src).toBeUndefined(); - expect(renderedOption.series?.[0]?.stack).toBeUndefined(); - expect(renderedOption.series?.[0]?.symbol).toBe('circle'); - expect(renderedOption.series?.[0]?.tooltip?.formatter).toBeUndefined(); - expect(renderedOption.series?.[0]?.tooltip).toEqual( - expect.objectContaining({ - appendToBody: false, - confine: true, - enterable: false, - renderMode: 'richText', - }), - ); - expect(renderedOption.series?.[1]?.name).toBe('Latency '); - expect(renderedOption.series?.[1]?.label?.formatter).toBe( - 'Results ', - ); - }); - - it('shows an error when sanitization strips every renderable chart entry', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - const container = await render( - , - ); - await flushChart(); - - expect(container.textContent).toContain( - 'Sanitized chart has no series or dataset; option keys may have been stripped.', - ); - expect(consoleError).toHaveBeenCalledWith( - '[web-shell] echarts-fulldata render failed:', - expect.any(Error), - ); - }); - - it('ignores malformed title array entries', async () => { - const option: EchartsFullDataOption = { - title: [ - null, - { text: 'Recovered title' }, - ] as unknown as EchartsFullDataOption['title'], - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - - expect(container.textContent).toContain('Recovered title'); - }); - - it('uses the default title when a single title object has empty text', async () => { - const option: EchartsFullDataOption = { - title: { text: '' }, - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - - const container = await render( - , - ); - - expect(container.querySelector('[title="Chart Loading"]')).not.toBeNull(); - }); - - it('shows an error when the chart runtime is unavailable', async () => { - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - - const container = await render( - , - ); - await flushChart(); - - expect(container.textContent).toContain('Chart runtime is unavailable.'); - expect( - container.querySelector('[data-testid="echarts-fulldata-chart"]'), - ).not.toBeNull(); - }); - - it('localizes chart chrome strings', async () => { - const option: EchartsFullDataOption = { - dataset: { - source: [{ day: 'Mon', orders: 120 }], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar' }], - }; - const { container } = await mount( - - - , - ); - await flushChart(); - - expect( - container - .querySelector('[data-testid="echarts-fulldata-rendered"]') - ?.getAttribute('aria-label'), - ).toBe('图表加载中'); - expect(container.textContent).toContain('图表运行时不可用。'); - expect( - container.querySelector('button[aria-label="显示图表"]'), - ).not.toBeNull(); - expect( - container.querySelector('button[aria-label="显示数据"]'), - ).not.toBeNull(); - expect(container.querySelector('[aria-label="视图模式"]')).not.toBeNull(); - }); - - it('caps the rendered data table rows', async () => { - const rows = Array.from({ length: 501 }, (_, index) => ({ - day: `Day ${index + 1}`, - orders: index + 1, - })); - const option: EchartsFullDataOption = { - dataset: { - dimensions: ['day', 'orders'], - source: rows, - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], - }; - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - await act(async () => { - container.querySelectorAll('button')[1]?.click(); - }); - - expect(container.textContent).toContain('Showing 500 of 501 rows'); - expect(container.querySelectorAll('tbody tr')).toHaveLength(500); - }); - - it('caps the rendered data table columns', async () => { - const columns = Array.from({ length: 60 }, (_, index) => `Metric ${index}`); - const option: EchartsFullDataOption = { - dataset: { - dimensions: columns, - source: [columns.map((_, index) => index)], - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 0, y: 1 } }], - }; - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption: vi.fn(), - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - - const container = await render( - runtime} - />, - ); - await flushChart(); - await act(async () => { - container.querySelectorAll('button')[1]?.click(); - }); - - expect(container.textContent).toContain('Showing 50 of 60 columns'); - expect(container.querySelectorAll('thead th')).toHaveLength(50); - expect(container.querySelectorAll('tbody td')).toHaveLength(50); - }); - - it('accepts chart data at the row and cell limits', async () => { - const setOption = vi.fn(); - const runtime: EchartsRuntime = { - init: vi.fn(() => ({ - setOption, - resize: vi.fn(), - dispose: vi.fn(), - })), - }; - const columns = Array.from({ length: 40 }, (_, index) => `metric${index}`); - const rows = Array.from({ length: 1000 }, () => columns.map(() => 1)); - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - dimensions: columns, - source: rows, - }, - xAxis: { type: 'category' }, - yAxis: { type: 'value' }, - series: [{ type: 'bar', encode: { x: 0, y: 1 } }], - }), - loadEcharts: () => runtime, - }); - await flushChart(); - - expect(container.textContent).not.toContain('too many'); - expect(setOption).toHaveBeenCalledOnce(); - }); - - it('rejects chart data over the row limit', async () => { - const rows = Array.from({ length: 2001 }, (_, index) => [index]); - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - source: rows, - }, - series: [{ type: 'bar' }], - }), - }); - - expect(container.textContent).toContain( - 'Chart data has too many rows. Maximum supported rows: 2000.', - ); - }); - - it('rejects chart data over the cell limit', async () => { - const columns = Array.from({ length: 40 }, (_, index) => `metric${index}`); - const rows = Array.from({ length: 1001 }, () => columns.map(() => 1)); - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - dimensions: columns, - source: rows, - }, - series: [{ type: 'bar' }], - }), - }); - - expect(container.textContent).toContain( - 'Chart data has too many cells. Maximum supported cells: 40000.', - ); - }); - - it('rejects chart data over the series limit', async () => { - const series = Array.from({ length: 101 }, () => ({ type: 'line' })); - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - source: [[1]], - }, - series, - }), - }); - - expect(container.textContent).toContain( - 'Chart data has too many series. Maximum supported series: 100.', - ); - }); - - it('rejects chart data over the nesting depth limit', async () => { - let nested: unknown = 'leaf'; - for (let index = 0; index < 42; index += 1) { - nested = { child: nested }; - } - - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - source: [[1]], - }, - series: [{ type: 'bar' }], - title: nested, - }), - }); - - expect(container.textContent).toContain('Chart data is too deeply nested.'); - }); - - it('rejects chart code over the size limit before parsing', async () => { - const container = await renderEchartsMarkdown({ - code: 'x'.repeat(500_001), - }); - - expect(container.textContent).toContain( - 'Chart data is too large. Maximum supported size: 500000 characters.', - ); - }); - - it('rejects chart data without dataset source rows', async () => { - const container = await renderEchartsMarkdown({ - code: JSON.stringify({ - dataset: { - source: [], - }, - series: [{ type: 'bar' }], - }), - }); - - expect(container.textContent).toContain( - 'Chart data must include dataset.source.', - ); - }); - - it('rejects syntactically valid JSON that is not an option object', async () => { - const container = await render( - - - - - , - ); - - expect(container.textContent).toContain( - 'Chart data must be a JSON object.', - ); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it('handles invalid chart JSON without falling back to the visible fence', async () => { - const container = await render( - - - - - , - ); - - expect( - container.querySelector('[data-testid="echarts-fulldata-rendered"]'), - ).not.toBeNull(); - expect(container.textContent).not.toContain('echarts-fulldata'); - expect(container.querySelector('pre code')).toBeNull(); - }); - - it('shows loading instead of parse errors while chart JSON is streaming', async () => { - const container = await render( - - - - - , - ); - - expect( - container.querySelector('[data-testid="echarts-fulldata-rendered"]'), - ).not.toBeNull(); - expect(container.querySelector('[role="status"]')).not.toBeNull(); - expect(container.textContent).not.toContain('Expected property'); - expect(container.textContent).not.toContain('echarts-fulldata'); - expect(container.querySelector('pre code')).toBeNull(); - }); -}); diff --git a/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx b/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx deleted file mode 100644 index f18fd93867d..00000000000 --- a/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx +++ /dev/null @@ -1,1889 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { - CodeBlockRenderer, - WebShellCodeBlockRenderInfo, -} from '../../customization'; -import type { WebShellTheme } from '../../themeContext'; -import { - EnhancedTable, - MAX_ENHANCED_TABLE_COLUMNS, - MAX_ENHANCED_TABLE_ROWS, - type EnhancedTableData, -} from './EnhancedMarkdownTable'; -import { useI18n } from '../../i18n'; -import styles from './EchartsFullDataBlock.module.css'; - -export const ECHARTS_FULLDATA_LANGUAGE = 'echarts-fulldata'; - -export type DatasetCell = string | number | boolean | null; -type DatasetRow = Record | DatasetCell[]; -type DatasetSource = DatasetRow[]; -type DatasetDimension = string | { name?: string }; -type EchartsObject = Record; -type ChartTheme = WebShellTheme; - -interface EchartsDataset { - dimensions?: DatasetDimension[]; - source?: DatasetSource; -} - -export interface EchartsFullDataOption { - title?: { text?: string } | Array<{ text?: string }>; - dataset?: EchartsDataset | EchartsDataset[]; - [key: string]: unknown; -} - -export interface EchartsInstance { - setOption(option: EchartsFullDataOption, opts?: { notMerge?: boolean }): void; - resize(): void; - dispose(): void; -} - -export interface EchartsRuntime { - init(element: HTMLElement, theme?: string): EchartsInstance; -} - -export type EchartsRuntimeLoader = () => - | EchartsRuntime - | Promise; - -export interface EchartsFullDataResolvedDataset { - dimensions: string[]; - source: DatasetCell[][]; -} - -export interface EchartsFullDataRefMeta { - dimensions: string[]; - format: 'csv' | 'json'; -} - -/** - * Resolves a renderer-validated data ref. The ref uses artifact:// or - * session-file:// with normalized non-empty path segments and no traversal, - * dot, drive-qualified, query/hash, whitespace, control, backslash, or - * double-encoded percent forms. - */ -export type EchartsFullDataRefResolver = ( - ref: string, - meta: EchartsFullDataRefMeta, -) => EchartsFullDataResolvedDataset | Promise; - -export interface EchartsFullDataBlockProps { - /** - * Chart option. Must be JSON-serializable; functions and other non-JSON - * values are stripped during internal cloning. - */ - option?: EchartsFullDataOption; - parseError?: string; - isStreaming?: boolean; - theme: ChartTheme; - loadEcharts?: EchartsRuntimeLoader; -} - -export interface EchartsFullDataRendererOptions { - loadEcharts?: EchartsRuntimeLoader; - resolveDataRef?: EchartsFullDataRefResolver; -} - -interface EchartsFullDataBlockFromCodeProps { - code: string; - isStreaming: boolean; - theme: ChartTheme; - loadEcharts?: EchartsRuntimeLoader; - resolveDataRef?: EchartsFullDataRefResolver; -} - -const CHART_THEME = { - light: { - background: '#ffffff', - foreground: '#343434', - muted: '#838d95', - border: '#e0e6f1', - axisLine: '#5d666f', - axisPointer: '#7c8a96', - tooltipBackground: '#ffffff', - tooltipShadow: '0 8px 24px rgba(15,23,42,0.12)', - primary: '#6250f9', - palette: [ - '#6250F9', - '#33AFA9', - '#AB7BFF', - '#5F99F9', - '#A9AFFF', - '#60CCC5', - '#C2A5FF', - '#8EB8FE', - '#E0E3FE', - '#98E3DD', - '#E8E1FA', - '#D7E6FF', - ], - }, - dark: { - background: '#0d0d0d', - foreground: '#f4f7ff', - muted: '#9aa3b7', - border: 'rgba(129,145,209,0.24)', - axisLine: '#657086', - axisPointer: '#8a98b3', - tooltipBackground: '#161616', - tooltipShadow: '0 10px 28px rgba(0,0,0,0.34)', - primary: '#8aa0ff', - palette: [ - '#8AA0FF', - '#60CCC5', - '#C2A5FF', - '#5F99F9', - '#A9AFFF', - '#33AFA9', - '#AB7BFF', - '#8EB8FE', - '#E0E3FE', - '#98E3DD', - '#E8E1FA', - '#D7E6FF', - ], - }, -} satisfies Record & { palette: string[] }>; - -const MAX_CHART_CODE_LENGTH = 500_000; -const MAX_OPTION_DEPTH = 40; -const MAX_DATA_ROWS = 2_000; -const MAX_DATA_CELLS = 40_000; -const MAX_SERIES_COUNT = 100; -const MAX_FORMATTER_TEMPLATE_LENGTH = 4_096; -const MAX_FORMATTER_DIMENSION_LENGTH = 256; -const DATA_REF_TIMEOUT_MS = 30_000; -const SUPPORTED_DATA_REF_PREFIXES = ['artifact://', 'session-file://']; -const SUPPORTED_DATA_REF_FORMATS = new Set(['csv', 'json']); -const UNSAFE_URI_WITH_AUTHORITY_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//; -const UNSAFE_DIMENSION_NAMES = new Set([ - '__proto__', - 'constructor', - 'prototype', -]); -const UNSAFE_OPTION_HTML_TAG_PATTERN = - /<\/?(?:a|applet|base|button|embed|form|iframe|img|input|link|meta|object|script|style|svg)(?=[\s/>])/i; -const ECHARTS_TEMPLATE_DIMENSION_METACHAR_PATTERN = /[|{}]/; -const WINDOWS_DRIVE_SEGMENT_PATTERN = /^[a-z]:/i; -const noop = () => {}; - -// Sanitization is intentionally two-layered: top-level option keys are an -// allowlist/default-deny surface, while nested keys are a denylist/default-allow -// surface. UNSAFE_OPTION_KEYS does not backstop SAFE_TOP_LEVEL_OPTION_KEYS; any -// key promoted to the top-level allowlist needs a fresh subtree review. -const SAFE_TOP_LEVEL_OPTION_KEYS = new Set([ - 'angleAxis', - 'backgroundColor', - 'color', - 'dataset', - 'dataZoom', - 'grid', - 'legend', - 'polar', - 'radar', - 'radiusAxis', - 'series', - 'textStyle', - 'title', - 'tooltip', - 'xAxis', - 'yAxis', -]); - -const UNSAFE_OPTION_KEYS = new Set([ - 'appendTo', - 'backgroundImage', - 'brush', - 'calendar', - 'cursor', - 'data', - 'extraCssText', - 'geo', - 'graphic', - 'href', - 'image', - 'link', - 'map', - 'media', - 'renderItem', - 'src', - 'target', - 'timeline', - 'toolbox', - 'url', -]); - -export const ECHARTS_FULLDATA_SANITIZER_KEY_OVERLAP = Object.freeze( - [...SAFE_TOP_LEVEL_OPTION_KEYS].filter((key) => UNSAFE_OPTION_KEYS.has(key)), -); - -function isObject(value: unknown): value is EchartsObject { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - -function mergeDefaults(defaults: EchartsObject, value: unknown): EchartsObject { - if (!isObject(value)) return { ...defaults }; - const merged: EchartsObject = { ...defaults, ...value }; - for (const key of Object.keys(defaults)) { - if (isObject(defaults[key]) && isObject(value[key])) { - merged[key] = mergeDefaults(defaults[key] as EchartsObject, value[key]); - } - } - return merged; -} - -function styleComponent(value: unknown, defaults: EchartsObject): unknown { - if (Array.isArray(value)) { - return value.map((entry) => - isObject(entry) ? mergeDefaults(defaults, entry) : entry, - ); - } - if (isObject(value)) return mergeDefaults(defaults, value); - return value == null ? { ...defaults } : value; -} - -function forceTooltipSafety(value: unknown, safeExtraCssText: string): unknown { - const force = (entry: unknown) => - isObject(entry) - ? { - ...entry, - appendToBody: false, - confine: true, - enterable: false, - extraCssText: safeExtraCssText, - renderMode: 'richText', - } - : entry; - - if (Array.isArray(value)) return value.map(force); - return force(value); -} - -function getSafeTooltipCss(tokens: (typeof CHART_THEME)[ChartTheme]): string { - return `border-radius:6px;box-shadow:${tokens.tooltipShadow};max-height:300px;overflow:auto;white-space:pre-wrap;`; -} - -function styleExistingObject(value: unknown, defaults: EchartsObject): unknown { - return isObject(value) ? mergeDefaults(defaults, value) : value; -} - -function styleAxis( - value: unknown, - getDefaults: (index: number) => EchartsObject, -): unknown { - if (Array.isArray(value)) { - return value.map((entry, index) => - isObject(entry) ? mergeDefaults(getDefaults(index), entry) : entry, - ); - } - if (isObject(value)) return mergeDefaults(getDefaults(0), value); - return value; -} - -function exceedsMaxDepth(value: unknown, maxDepth: number): boolean { - const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; - - while (stack.length > 0) { - const current = stack.pop(); - if (!current) continue; - if (current.depth > maxDepth) return true; - if (!current.value || typeof current.value !== 'object') continue; - - const entries = Array.isArray(current.value) - ? current.value - : Object.values(current.value); - for (const entry of entries) { - stack.push({ value: entry, depth: current.depth + 1 }); - } - } - - return false; -} - -function isUnsafeUriString(value: string): boolean { - const normalized = value.trim().toLowerCase(); - return ( - normalized.startsWith('//') || - UNSAFE_URI_WITH_AUTHORITY_PATTERN.test(normalized) || - normalized.startsWith('blob:') || - normalized.startsWith('data:') || - normalized.startsWith('image://') || - normalized.startsWith('javascript:') || - normalized.startsWith('vbscript:') - ); -} - -function isUnsafeOptionString(value: string): boolean { - return isUnsafeUriString(value) || UNSAFE_OPTION_HTML_TAG_PATTERN.test(value); -} - -function isUnsafeDimensionName(value: string): boolean { - return UNSAFE_DIMENSION_NAMES.has(value) || isUnsafeOptionString(value); -} - -function sanitizeDatasetDimension( - dimension: unknown, -): DatasetDimension | undefined { - if (typeof dimension === 'number' && Number.isFinite(dimension)) { - return String(dimension); - } - if (typeof dimension === 'string') { - return isUnsafeDimensionName(dimension) ? {} : dimension; - } - if (isObject(dimension) && typeof dimension.name === 'string') { - return isUnsafeDimensionName(dimension.name) - ? {} - : { name: dimension.name }; - } - return isObject(dimension) ? {} : undefined; -} - -function getUnsafeDimensionError(dimensions: string[]): string | undefined { - return dimensions.some(isUnsafeDimensionName) - ? 'Chart envelope data.dimensions contains an unsafe dimension name.' - : undefined; -} - -function sanitizeDatasetCell(value: unknown): DatasetCell { - if (typeof value === 'string') return isUnsafeUriString(value) ? '' : value; - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'boolean' || value === null) return value; - return ''; -} - -function sanitizeDatasetRow(row: unknown): DatasetRow | undefined { - if (Array.isArray(row)) return row.map(sanitizeDatasetCell); - if (!isObject(row)) return undefined; - - const sanitized: Record = {}; - for (const [key, value] of Object.entries(row)) { - if (key === '__proto__') continue; - sanitized[key] = sanitizeDatasetCell(value); - } - return sanitized; -} - -function isAllowedAnnotationData(path: string[]): boolean { - const parentKey = path.at(-1); - return ( - parentKey === 'markLine' || - parentKey === 'markPoint' || - parentKey === 'markArea' - ); -} - -function sanitizeOptionValue(value: unknown, path: string[] = []): unknown { - if (Array.isArray(value)) { - return value.map((entry) => sanitizeOptionValue(entry, path) ?? null); - } - - if (isObject(value)) { - const sanitized: EchartsObject = {}; - for (const [key, entry] of Object.entries(value)) { - if (key === '__proto__') continue; - if ( - UNSAFE_OPTION_KEYS.has(key) && - !(key === 'data' && isAllowedAnnotationData(path)) - ) { - continue; - } - if (key === 'formatter' && path.includes('tooltip')) continue; - - const next = sanitizeOptionValue(entry, [...path, key]); - if (next !== undefined) sanitized[key] = next; - } - return sanitized; - } - - if (typeof value === 'string' && isUnsafeOptionString(value)) - return undefined; - return value; -} - -function sanitizeDatasetValue(value: unknown): unknown { - const sanitizeDataset = (entry: unknown) => { - if (!isObject(entry)) return undefined; - const dataset: EchartsDataset = {}; - if (Array.isArray(entry.dimensions)) { - dataset.dimensions = entry.dimensions - .map(sanitizeDatasetDimension) - .filter( - (dimension): dimension is DatasetDimension => dimension !== undefined, - ); - } - if (Array.isArray(entry.source)) { - dataset.source = entry.source - .map(sanitizeDatasetRow) - .filter((row): row is DatasetRow => !!row); - } - return dataset; - }; - - if (Array.isArray(value)) { - const datasets = value - .map(sanitizeDataset) - .filter((entry): entry is EchartsDataset => !!entry); - return datasets.length > 0 ? datasets : undefined; - } - return sanitizeDataset(value); -} - -function sanitizeOptionForChart( - option: EchartsFullDataOption, -): EchartsFullDataOption { - const sanitized: EchartsFullDataOption = {}; - for (const [key, value] of Object.entries(option)) { - if (!SAFE_TOP_LEVEL_OPTION_KEYS.has(key)) continue; - const next = - key === 'dataset' - ? sanitizeDatasetValue(value) - : sanitizeOptionValue(value, [key]); - if (next !== undefined) sanitized[key] = next; - } - return sanitized; -} - -function getSeriesList(series: unknown): EchartsObject[] { - if (Array.isArray(series)) return series.filter(isObject); - return isObject(series) ? [series] : []; -} - -function hasRenderableChartData(option: EchartsFullDataOption): boolean { - return getSeriesList(option.series).length > 0 || !!option.dataset; -} - -function getEncodedDimension( - value: unknown, - dimensions: string[], -): string | undefined { - const candidate = Array.isArray(value) ? value[0] : value; - if (typeof candidate === 'string') { - return dimensions.includes(candidate) ? candidate : undefined; - } - if (typeof candidate === 'number' && Number.isInteger(candidate)) { - return ( - dimensions[candidate] ?? - dimensions.find((dimension) => dimension === String(candidate)) - ); - } - return undefined; -} - -function normalizeDatasetValueFormatter( - formatter: unknown, - dimension: string | undefined, -): unknown { - if (typeof formatter !== 'string' || !dimension) return formatter; - if (ECHARTS_TEMPLATE_DIMENSION_METACHAR_PATTERN.test(dimension)) { - return formatter; - } - if ( - formatter.length > MAX_FORMATTER_TEMPLATE_LENGTH || - dimension.length > MAX_FORMATTER_DIMENSION_LENGTH - ) { - return formatter; - } - return formatter.replace( - /\{c(?::([^}]*))?\}/g, - (_match: string, format?: string) => - format ? `{@${dimension}|${format}}` : `{@${dimension}}`, - ); -} - -function normalizeSeriesDatasetFormatters( - series: EchartsObject, - dimensions: string[], -): EchartsObject { - const encode = isObject(series.encode) ? series.encode : undefined; - const yDimension = getEncodedDimension(encode?.y, dimensions); - if (!yDimension || !isObject(series.label)) return series; - - return { - ...series, - label: { - ...series.label, - formatter: normalizeDatasetValueFormatter( - series.label.formatter, - yDimension, - ), - }, - }; -} - -function normalizeObjectDatasetFormatters( - option: EchartsFullDataOption, -): EchartsFullDataOption { - const dimensions = getColumns(option); - if (dimensions.length === 0 || !('series' in option)) return option; - - if (Array.isArray(option.series)) { - return { - ...option, - series: option.series.map((entry) => - isObject(entry) - ? normalizeSeriesDatasetFormatters(entry, dimensions) - : entry, - ), - }; - } - - return isObject(option.series) - ? { - ...option, - series: normalizeSeriesDatasetFormatters(option.series, dimensions), - } - : option; -} - -function getTooltipTrigger(option: EchartsFullDataOption): 'axis' | 'item' { - const hasAxis = 'xAxis' in option || 'yAxis' in option; - if (!hasAxis) return 'item'; - const series = getSeriesList(option.series); - const itemOnlyTypes = new Set(['pie', 'funnel', 'gauge', 'radar', 'treemap']); - if ( - series.length > 0 && - series.every((entry) => itemOnlyTypes.has(String(entry.type))) - ) { - return 'item'; - } - return 'axis'; -} - -function styleSeriesEntry( - series: EchartsObject, - tokens: (typeof CHART_THEME)[ChartTheme], -): EchartsObject { - const type = typeof series.type === 'string' ? series.type : undefined; - let defaults: EchartsObject = { - emphasis: { - focus: 'series', - itemStyle: { - borderColor: tokens.background, - borderWidth: 2, - }, - }, - labelLayout: { - hideOverlap: true, - }, - }; - - if (type === 'line') { - defaults = mergeDefaults(defaults, { - lineStyle: { - width: 2, - }, - itemStyle: { - borderWidth: 1, - }, - symbol: 'circle', - symbolSize: 4, - }); - } else if (type === 'bar') { - defaults = mergeDefaults(defaults, { - barCategoryGap: '48%', - barMaxWidth: 42, - itemStyle: { - borderRadius: [3, 3, 0, 0], - }, - }); - } else if (type === 'pie') { - defaults = mergeDefaults(defaults, { - itemStyle: { - borderColor: tokens.background, - borderWidth: 2, - }, - }); - } - - const styled = mergeDefaults(defaults, series); - const safeTooltipCss = getSafeTooltipCss(tokens); - if ('tooltip' in series) { - styled.tooltip = forceTooltipSafety( - styleComponent(series.tooltip, { - appendToBody: false, - confine: true, - enterable: false, - extraCssText: safeTooltipCss, - renderMode: 'richText', - }), - safeTooltipCss, - ); - } - const annotationLabel = { - color: tokens.foreground, - fontSize: 12, - textBorderColor: tokens.background, - textBorderWidth: 2, - }; - - if (isObject(series.label)) { - styled.label = styleExistingObject(series.label, { - color: tokens.foreground, - fontSize: 12, - textBorderColor: tokens.background, - textBorderWidth: 2, - }); - } - if (isObject(series.markPoint)) { - styled.markPoint = styleExistingObject(series.markPoint, { - itemStyle: { - borderColor: tokens.background, - borderWidth: 1, - }, - label: annotationLabel, - }); - } - if (isObject(series.markLine)) { - styled.markLine = styleExistingObject(series.markLine, { - label: { - ...annotationLabel, - color: tokens.muted, - }, - lineStyle: { - color: tokens.primary, - type: 'dashed', - width: 1.5, - }, - symbol: ['none', 'none'], - }); - } - - return styled; -} - -function styleSeries( - value: unknown, - tokens: (typeof CHART_THEME)[ChartTheme], -): unknown { - if (Array.isArray(value)) { - return value.map((entry) => - isObject(entry) ? styleSeriesEntry(entry, tokens) : entry, - ); - } - return isObject(value) ? styleSeriesEntry(value, tokens) : value; -} - -function applyDefaultChartStyle( - option: EchartsFullDataOption, - theme: ChartTheme, -): EchartsFullDataOption { - const tokens = CHART_THEME[theme]; - const styled: EchartsFullDataOption = { ...option }; - - styled.backgroundColor = option.backgroundColor ?? tokens.background; - styled.color = option.color ?? [...tokens.palette]; - styled.textStyle = styleComponent(option.textStyle, { - color: tokens.foreground, - fontFamily: - "'pingfang SC', 'helvetica neue', arial, 'hiragino sans gb', 'microsoft yahei ui', 'microsoft yahei', sans-serif", - }); - styled.grid = styleComponent(option.grid, { - top: 24, - right: 36, - bottom: 48, - left: 24, - containLabel: true, - }); - const safeTooltipCss = getSafeTooltipCss(tokens); - styled.tooltip = forceTooltipSafety( - styleComponent(option.tooltip, { - trigger: getTooltipTrigger(option), - confine: true, - enterable: false, - renderMode: 'richText', - backgroundColor: tokens.tooltipBackground, - borderColor: tokens.border, - borderWidth: 1, - padding: [8, 10], - textStyle: { - color: tokens.foreground, - fontSize: 12, - }, - axisPointer: { - lineStyle: { - color: tokens.axisPointer, - width: 1, - }, - crossStyle: { - color: tokens.axisPointer, - width: 1, - }, - }, - extraCssText: safeTooltipCss, - }), - safeTooltipCss, - ); - styled.legend = styleComponent(option.legend, { - type: 'scroll', - bottom: 8, - padding: [4, 16], - textStyle: { - color: tokens.muted, - fontSize: 12, - }, - pageIconColor: tokens.primary, - pageIconInactiveColor: tokens.border, - pageTextStyle: { - color: tokens.muted, - }, - }); - - if ('xAxis' in option) { - styled.xAxis = styleAxis(option.xAxis, () => ({ - axisLine: { - show: true, - lineStyle: { - color: tokens.axisLine, - }, - }, - axisTick: { - show: true, - lineStyle: { - color: tokens.axisLine, - }, - }, - axisLabel: { - color: tokens.muted, - fontSize: 12, - hideOverlap: true, - }, - splitLine: { - show: false, - lineStyle: { - color: tokens.border, - }, - }, - nameTextStyle: { - color: tokens.muted, - }, - })); - } - - if ('yAxis' in option) { - styled.yAxis = styleAxis(option.yAxis, (index) => ({ - alignTicks: true, - axisLine: { - show: false, - lineStyle: { - color: tokens.axisLine, - }, - }, - axisTick: { - show: false, - lineStyle: { - color: tokens.axisLine, - }, - }, - axisLabel: { - color: tokens.muted, - fontSize: 12, - hideOverlap: true, - }, - splitLine: { - show: index === 0, - lineStyle: { - color: tokens.border, - }, - }, - nameGap: 12, - nameTextStyle: { - color: tokens.muted, - align: index === 0 ? 'left' : 'right', - }, - })); - } - - if ('series' in option) { - styled.series = styleSeries(option.series, tokens); - } - - return styled; -} - -function cloneAndSanitizeOptionForChart( - option: EchartsFullDataOption, -): EchartsFullDataOption { - // The component is exported, so callers can pass programmatic options; clone - // first to strip non-JSON values before the sanitizer walks the tree. - const cloned = JSON.parse(JSON.stringify(option)) as EchartsFullDataOption; - const normalized = normalizeObjectDatasetFormatters(cloned); - const sanitized = sanitizeOptionForChart(normalized); - if (!hasRenderableChartData(sanitized)) { - throw new Error( - 'Sanitized chart has no series or dataset; option keys may have been stripped.', - ); - } - return sanitized; -} - -function styleOptionForChart( - option: EchartsFullDataOption, - theme: ChartTheme, -): EchartsFullDataOption { - const styled = applyDefaultChartStyle(option, theme); - delete styled.title; - return styled; -} - -function getPrimaryDataset( - option: EchartsFullDataOption | undefined, -): EchartsDataset | undefined { - if (!option) return undefined; - return Array.isArray(option.dataset) ? option.dataset[0] : option.dataset; -} - -function getTitle( - option: EchartsFullDataOption | undefined, -): string | undefined { - const title = option?.title; - if (Array.isArray(title)) { - return title.find( - (entry): entry is { text: string } => - isObject(entry) && typeof entry.text === 'string' && !!entry.text, - )?.text; - } - return isObject(title) && typeof title.text === 'string' && !!title.text - ? title.text - : undefined; -} - -function getRows(option: EchartsFullDataOption | undefined): DatasetSource { - const source = getPrimaryDataset(option)?.source; - return Array.isArray(source) ? source : []; -} - -function normalizeDimensions( - dimensions: DatasetDimension[] | undefined, -): string[] { - if (!Array.isArray(dimensions)) return []; - return dimensions.map((dimension, index) => { - if (typeof dimension === 'string') return dimension; - if (typeof dimension === 'number' && Number.isFinite(dimension)) { - return String(dimension); - } - return isObject(dimension) && - typeof dimension.name === 'string' && - dimension.name - ? dimension.name - : String(index); - }); -} - -function getColumns(option: EchartsFullDataOption | undefined): string[] { - const dataset = getPrimaryDataset(option); - const explicit = normalizeDimensions(dataset?.dimensions); - if (explicit.length > 0) return explicit; - - const first = getRows(option)[0]; - if (!first) return []; - if (Array.isArray(first)) { - return first.map((_, index) => String(index + 1)); - } - return Object.keys(first); -} - -function getCell( - row: DatasetRow, - column: string, - columnIndex: number, -): DatasetCell | undefined { - return Array.isArray(row) - ? row[columnIndex] - : Object.hasOwn(row, column) - ? row[column] - : undefined; -} - -function formatCell(value: DatasetCell | undefined): string { - if (value == null) return ''; - return String(value); -} - -function hasControlCharacter(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code <= 0x1f || code === 0x7f) return true; - } - return false; -} - -function hasWhitespaceOrControl(value: string): boolean { - return /\s/.test(value) || hasControlCharacter(value); -} - -type EchartsFullDataRefDescriptor = { - ref: string; - meta: EchartsFullDataRefMeta; - option: EchartsFullDataOption; -}; - -type ParseOptionResult = { - option?: EchartsFullDataOption; - parseError?: string; - dataRef?: EchartsFullDataRefDescriptor; -}; - -function validateDatasetSize( - rowCount: number, - columnCount: number, - cellCount = rowCount * Math.max(columnCount, 1), -): string | undefined { - if (rowCount === 0) { - return 'Chart data must include dataset.source.'; - } - if (rowCount > MAX_DATA_ROWS) { - return `Chart data has too many rows. Maximum supported rows: ${MAX_DATA_ROWS}.`; - } - - if (cellCount > MAX_DATA_CELLS) { - return `Chart data has too many cells. Maximum supported cells: ${MAX_DATA_CELLS}.`; - } - - return undefined; -} - -function validateDatasetCell( - value: unknown, - rowIndex: number, - cellIndex: number, -): string | undefined { - if ( - typeof value === 'string' || - typeof value === 'boolean' || - value === null - ) { - return undefined; - } - if (typeof value === 'number' && Number.isFinite(value)) { - return undefined; - } - return `Chart data row ${rowIndex + 1} cell ${cellIndex + 1} must be a string, number, boolean, or null.`; -} - -function validateDatasetRows( - option: EchartsFullDataOption, -): string | undefined { - const rows = getRows(option); - const columns = getColumns(option); - let cellCount = 0; - let maxColumnCount = columns.length; - - for (const [rowIndex, row] of rows.entries()) { - if (Array.isArray(row)) { - if (columns.length > 0 && row.length !== columns.length) { - return `Chart data row ${rowIndex + 1} has ${row.length} cells; expected ${columns.length}.`; - } - maxColumnCount = Math.max(maxColumnCount, row.length); - cellCount += row.length; - for (const [cellIndex, cell] of row.entries()) { - const cellError = validateDatasetCell(cell, rowIndex, cellIndex); - if (cellError) return cellError; - } - continue; - } - - if (!isObject(row)) { - return `Chart data row ${rowIndex + 1} must be an object or array.`; - } - - const values = Object.values(row); - maxColumnCount = Math.max(maxColumnCount, values.length); - cellCount += values.length; - for (const [cellIndex, cell] of values.entries()) { - const cellError = validateDatasetCell(cell, rowIndex, cellIndex); - if (cellError) return cellError; - } - } - - return validateDatasetSize(rows.length, maxColumnCount, cellCount); -} - -function validateDatasetDimensions( - option: EchartsFullDataOption, -): string | undefined { - const datasets = Array.isArray(option.dataset) - ? option.dataset - : option.dataset - ? [option.dataset] - : []; - - for (const dataset of datasets) { - if (!Array.isArray(dataset.dimensions)) continue; - const error = getUnsafeDimensionError( - normalizeDimensions(dataset.dimensions), - ); - if (error) return error; - } - - return undefined; -} - -function validateOptionShape( - option: EchartsFullDataOption, -): string | undefined { - if (exceedsMaxDepth(option, MAX_OPTION_DEPTH)) { - return 'Chart data is too deeply nested.'; - } - - const datasetError = validateDatasetRows(option); - if (datasetError) return datasetError; - - const dimensionError = validateDatasetDimensions(option); - if (dimensionError) return dimensionError; - - const seriesCount = getSeriesList(option.series).length; - if (seriesCount > MAX_SERIES_COUNT) { - return `Chart data has too many series. Maximum supported series: ${MAX_SERIES_COUNT}.`; - } - - return undefined; -} - -function normalizeEnvelopeDataset(value: unknown): { - dataset?: EchartsFullDataResolvedDataset; - parseError?: string; -} { - if (!isObject(value)) { - return { parseError: 'Chart envelope data must be an object.' }; - } - - const dimensions = value.dimensions; - if ( - !Array.isArray(dimensions) || - dimensions.length === 0 || - !dimensions.every((dimension) => typeof dimension === 'string') - ) { - return { - parseError: 'Chart envelope data.dimensions must be a string array.', - }; - } - const dimensionError = getUnsafeDimensionError(dimensions); - if (dimensionError) return { parseError: dimensionError }; - - const source = value.source; - if (!Array.isArray(source)) { - return { - parseError: 'Chart envelope data.source must be an array of rows.', - }; - } - - const sizeError = validateDatasetSize(source.length, dimensions.length); - if (sizeError) return { parseError: sizeError }; - - const rows: DatasetCell[][] = []; - for (const [rowIndex, row] of source.entries()) { - if (!Array.isArray(row)) { - return { - parseError: `Chart envelope data.source row ${rowIndex + 1} must be an array.`, - }; - } - if (row.length !== dimensions.length) { - return { - parseError: `Chart envelope data.source row ${rowIndex + 1} has ${row.length} cells; expected ${dimensions.length}.`, - }; - } - - const nextRow: DatasetCell[] = []; - for (const [cellIndex, cell] of row.entries()) { - const cellError = validateDatasetCell(cell, rowIndex, cellIndex); - if (cellError) { - return { - parseError: cellError.replace( - 'Chart data row', - 'Chart envelope data.source row', - ), - }; - } - nextRow.push(cell as DatasetCell); - } - rows.push(nextRow); - } - - return { dataset: { dimensions: [...dimensions], source: rows } }; -} - -function injectDatasetAndValidate( - option: EchartsFullDataOption, - dataset: EchartsFullDataResolvedDataset, -): ParseOptionResult { - const normalized: EchartsFullDataOption = { - ...option, - dataset: { - dimensions: dataset.dimensions, - source: dataset.source, - }, - }; - const shapeError = validateOptionShape(normalized); - return shapeError ? { parseError: shapeError } : { option: normalized }; -} - -function isEchartsFullDataEnvelope(value: EchartsObject): boolean { - return 'version' in value && 'data' in value && 'option' in value; -} - -function normalizeDataRef(ref: unknown): { - ref?: string; - parseError?: string; -} { - if (typeof ref !== 'string' || ref.trim().length === 0) { - return { - parseError: 'Chart envelope data.ref must be a non-empty string.', - }; - } - const trimmed = ref.trim(); - if (trimmed !== ref || hasWhitespaceOrControl(ref)) { - return { - parseError: - 'Chart envelope data.ref must not contain whitespace or control characters.', - }; - } - const lower = trimmed.toLowerCase(); - const prefix = SUPPORTED_DATA_REF_PREFIXES.find((candidate) => - lower.startsWith(candidate), - ); - if (!prefix) { - return { - parseError: - 'Chart envelope data.ref must use artifact:// or session-file://.', - }; - } - - const rawPath = trimmed.slice(prefix.length); - if (rawPath.length === 0) { - return { parseError: 'Chart envelope data.ref path must be non-empty.' }; - } - if (/[?#\\]/.test(rawPath)) { - return { - parseError: - 'Chart envelope data.ref path must not include query, hash, or backslash characters.', - }; - } - - let decodedPath: string; - try { - decodedPath = decodeURIComponent(rawPath); - } catch { - return { parseError: 'Chart envelope data.ref path is malformed.' }; - } - if (/[%?#\\]/.test(decodedPath) || hasWhitespaceOrControl(decodedPath)) { - return { - parseError: - 'Chart envelope data.ref path must not include query, hash, whitespace, control, backslash, or double-encoded percent characters.', - }; - } - - const segments = decodedPath.split('/'); - if ( - segments.some( - (segment) => - segment.length === 0 || - segment === '.' || - segment === '..' || - WINDOWS_DRIVE_SEGMENT_PATTERN.test(segment), - ) - ) { - return { - parseError: - 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', - }; - } - return { ref: `${prefix}${segments.join('/')}` }; -} - -function normalizeDataRefMeta(data: EchartsObject): { - meta?: EchartsFullDataRefMeta; - parseError?: string; -} { - const dimensions = data.dimensions; - if ( - !Array.isArray(dimensions) || - dimensions.length === 0 || - !dimensions.every((dimension) => typeof dimension === 'string') - ) { - return { - parseError: 'Chart envelope data.dimensions must be a string array.', - }; - } - const dimensionError = getUnsafeDimensionError(dimensions); - if (dimensionError) return { parseError: dimensionError }; - - const format = data.format; - if (typeof format !== 'string' || !SUPPORTED_DATA_REF_FORMATS.has(format)) { - return { - parseError: 'Chart envelope data.format must be "csv" or "json".', - }; - } - - return { - meta: { - dimensions: [...dimensions], - format: format as EchartsFullDataRefMeta['format'], - }, - }; -} - -function normalizeEnvelope(envelope: EchartsObject): ParseOptionResult { - if (envelope.version !== 1) { - return { parseError: 'Chart envelope version must be 1.' }; - } - if (!isObject(envelope.option)) { - return { parseError: 'Chart envelope option must be an object.' }; - } - if (!isObject(envelope.data)) { - return { parseError: 'Chart envelope data must be an object.' }; - } - - const option = envelope.option as EchartsFullDataOption; - const { data } = envelope; - if (data.kind === 'inline') { - const { dataset, parseError } = normalizeEnvelopeDataset(data); - if (parseError || !dataset) return { parseError }; - return injectDatasetAndValidate(option, dataset); - } - - if (data.kind !== 'ref') { - return { - parseError: 'Chart envelope data.kind must be "inline" or "ref".', - }; - } - - const { ref, parseError } = normalizeDataRef(data.ref); - if (parseError || !ref) return { parseError }; - const { meta, parseError: metaError } = normalizeDataRefMeta(data); - if (metaError || !meta) return { parseError: metaError }; - return { dataRef: { ref, meta, option } }; -} - -function resolveDataRefWithTimeout( - resolveDataRef: EchartsFullDataRefResolver, - ref: string, - meta: EchartsFullDataRefMeta, -): Promise { - return new Promise((resolve, reject) => { - const timeoutId = globalThis.setTimeout(() => { - console.warn( - '[web-shell] echarts-fulldata data-ref resolution timed out after %dms (ref=%s, format=%s)', - DATA_REF_TIMEOUT_MS, - ref, - meta.format, - ); - reject(new Error('Data reference resolution timed out.')); - }, DATA_REF_TIMEOUT_MS); - - Promise.resolve() - .then(() => resolveDataRef(ref, meta)) - .then(resolve, reject) - .finally(() => globalThis.clearTimeout(timeoutId)); - }); -} - -function isResolvedDataset( - value: unknown, -): value is EchartsFullDataResolvedDataset { - return ( - isObject(value) && - Array.isArray(value.dimensions) && - Array.isArray(value.source) - ); -} - -function resolveEnvelopeDataRef( - resolveDataRef: EchartsFullDataRefResolver, - dataRef: EchartsFullDataRefDescriptor, -): Promise { - return resolveDataRefWithTimeout(resolveDataRef, dataRef.ref, dataRef.meta) - .then((resolved) => { - if (!isResolvedDataset(resolved)) { - console.error( - '[web-shell] echarts-fulldata resolver returned invalid dataset:', - dataRef.ref, - resolved, - ); - return { - parseError: 'Chart data resolver returned an invalid dataset.', - }; - } - const result = normalizeEnvelopeDataset({ - kind: 'inline', - dimensions: resolved.dimensions, - source: resolved.source, - }); - if (result.parseError || !result.dataset) { - return { parseError: result.parseError }; - } - return injectDatasetAndValidate(dataRef.option, result.dataset); - }) - .catch((error: unknown) => { - console.error( - '[web-shell] echarts-fulldata data-ref resolution failed:', - dataRef.ref, - error, - ); - return { - parseError: 'Chart data reference could not be resolved.', - }; - }); -} - -function parseOptionInner(code: string): ParseOptionResult { - if (code.length > MAX_CHART_CODE_LENGTH) { - return { - parseError: `Chart data is too large. Maximum supported size: ${MAX_CHART_CODE_LENGTH} characters.`, - }; - } - - try { - const parsed = JSON.parse(code) as unknown; - if (!isObject(parsed)) { - return { parseError: 'Chart data must be a JSON object.' }; - } - if (exceedsMaxDepth(parsed, MAX_OPTION_DEPTH)) { - return { parseError: 'Chart data is too deeply nested.' }; - } - if (isEchartsFullDataEnvelope(parsed)) { - return normalizeEnvelope(parsed); - } - const option = parsed as EchartsFullDataOption; - const shapeError = validateOptionShape(option); - if (shapeError) return { parseError: shapeError }; - return { option }; - } catch (error) { - return { - parseError: - error instanceof Error ? error.message : 'Chart data is invalid.', - }; - } -} - -function parseOption(code: string): ParseOptionResult { - const result = parseOptionInner(code); - if (result.parseError) { - console.warn( - '[web-shell] echarts-fulldata parse failed: %s (code length=%d)', - result.parseError, - code.length, - ); - } - return result; -} - -function getDataRefKey( - dataRef: EchartsFullDataRefDescriptor, - code: string, -): string { - return JSON.stringify([ - dataRef.ref, - dataRef.meta.format, - dataRef.meta.dimensions, - code, - ]); -} - -export function createEchartsFullDataRenderer({ - loadEcharts, - resolveDataRef, -}: EchartsFullDataRendererOptions = {}): CodeBlockRenderer { - return function renderEchartsFullDataBlock( - info: WebShellCodeBlockRenderInfo, - ) { - if ( - info.source !== 'assistant' || - info.language.toLowerCase() !== ECHARTS_FULLDATA_LANGUAGE - ) { - return undefined; - } - return ( - - ); - }; -} - -function EchartsFullDataBlockFromCode({ - code, - isStreaming, - theme, - loadEcharts, - resolveDataRef, -}: EchartsFullDataBlockFromCodeProps) { - const resolveDataRefRef = useRef(resolveDataRef); - resolveDataRefRef.current = resolveDataRef; - const hasResolveDataRef = !!resolveDataRef; - const stableResolveDataRef = useCallback( - (ref, meta) => { - const resolver = resolveDataRefRef.current; - if (!resolver) { - throw new Error('Chart data reference resolver is unavailable.'); - } - return resolver(ref, meta); - }, - [], - ); - const parsed = useMemo( - () => (isStreaming ? {} : parseOption(code)), - [code, isStreaming], - ); - const [resolvedParsed, setResolvedParsed] = useState({}); - const { dataRef } = parsed; - const dataRefKey = dataRef ? getDataRefKey(dataRef, code) : undefined; - const dataRefRef = useRef(dataRef); - const resolvedDataRefCacheRef = useRef< - { key: string; result: ParseOptionResult } | undefined - >(undefined); - dataRefRef.current = dataRef; - - useEffect(() => { - const currentDataRef = dataRefRef.current; - if (!dataRefKey || !currentDataRef) { - return; - } - if (!hasResolveDataRef) { - setResolvedParsed({ - parseError: 'Chart data reference resolver is unavailable.', - }); - return; - } - if (resolvedDataRefCacheRef.current?.key === dataRefKey) { - setResolvedParsed(resolvedDataRefCacheRef.current.result); - return; - } - - let cancelled = false; - setResolvedParsed({}); - resolveEnvelopeDataRef(stableResolveDataRef, currentDataRef).then( - (result) => { - if (!cancelled) { - resolvedDataRefCacheRef.current = { key: dataRefKey, result }; - setResolvedParsed(result); - } - }, - ); - return () => { - cancelled = true; - }; - }, [dataRefKey, hasResolveDataRef, stableResolveDataRef]); - - const { option, parseError } = dataRef ? resolvedParsed : parsed; - - return ( - - ); -} - -function ChartIcon() { - return ( - - ); -} - -function DataIcon() { - return ( - - ); -} - -function toEnhancedTableData( - columns: string[], - rows: DatasetSource, -): EnhancedTableData { - return { - headers: columns.map((column, columnIndex) => ({ - key: `header-${columnIndex}-${column}`, - content: column, - text: column, - isHeader: true, - })), - rows: rows.map((row, rowIndex) => ({ - key: `row-${rowIndex}`, - cells: columns.map((column, columnIndex) => { - const text = formatCell(getCell(row, column, columnIndex)); - return { - key: `row-${rowIndex}-${columnIndex}`, - content: text, - text, - isHeader: false, - }; - }), - })), - columnCount: columns.length, - }; -} - -function SimpleDatasetTable({ - columns, - rows, -}: { - columns: string[]; - rows: DatasetSource; -}) { - const { t } = useI18n(); - const visibleRows = rows.slice(0, MAX_ENHANCED_TABLE_ROWS); - const visibleColumns = columns.slice(0, MAX_ENHANCED_TABLE_COLUMNS); - const omittedRows = rows.length - visibleRows.length; - const omittedColumns = columns.length - visibleColumns.length; - const isTruncated = omittedRows > 0 || omittedColumns > 0; - - return ( -
- {isTruncated && ( -
- {t('echartsChart.tableNotice', { - visibleRows: visibleRows.length, - totalRows: rows.length, - visibleColumns: visibleColumns.length, - totalColumns: columns.length, - omittedRows, - omittedColumns, - })} -
- )} - - - - {visibleColumns.map((column, columnIndex) => ( - - ))} - - - - {visibleRows.map((row, rowIndex) => ( - - {visibleColumns.map((column, columnIndex) => ( - - ))} - - ))} - -
{column}
- {formatCell(getCell(row, column, columnIndex))} -
-
- ); -} - -function EchartsDataTable({ - columns, - rows, -}: { - columns: string[]; - rows: DatasetSource; -}) { - const { t } = useI18n(); - const isEmpty = columns.length === 0 || rows.length === 0; - const isOversized = - rows.length > MAX_ENHANCED_TABLE_ROWS || - columns.length > MAX_ENHANCED_TABLE_COLUMNS; - const table = useMemo( - () => - isEmpty || isOversized ? undefined : toEnhancedTableData(columns, rows), - [columns, isEmpty, isOversized, rows], - ); - - if (isEmpty) { - return
{t('echartsChart.noData')}
; - } - - if (isOversized) { - return ; - } - - return ( -
- -
- ); -} - -function ChartLoadingState() { - const { t } = useI18n(); - return ( -
-
- ); -} - -function loadEchartsWithTimeout( - loadRuntime: EchartsRuntimeLoader, -): Promise { - return new Promise((resolve, reject) => { - const timeoutId = globalThis.setTimeout(() => { - console.warn( - '[web-shell] echarts-fulldata runtime load timed out after %dms', - DATA_REF_TIMEOUT_MS, - ); - reject(new Error('Chart runtime load timed out.')); - }, DATA_REF_TIMEOUT_MS); - - Promise.resolve() - .then(loadRuntime) - .then(resolve, reject) - .finally(() => globalThis.clearTimeout(timeoutId)); - }); -} - -export function EchartsFullDataBlock({ - option, - parseError, - isStreaming = false, - theme, - loadEcharts, -}: EchartsFullDataBlockProps) { - const { t } = useI18n(); - const chartRef = useRef(null); - const chartInstanceRef = useRef(undefined); - const removeResizeRef = useRef<() => void>(noop); - const loadEchartsRef = useRef(loadEcharts); - const tRef = useRef(t); - const renderRequestRef = useRef(0); - const chartThemeRef = useRef(undefined); - const [mode, setMode] = useState<'chart' | 'data'>('chart'); - const [chartError, setChartError] = useState(null); - const [chartReady, setChartReady] = useState(false); - const { sanitizedOption, chartOptionError } = useMemo(() => { - if (!option || parseError) return {}; - try { - return { sanitizedOption: cloneAndSanitizeOptionForChart(option) }; - } catch (error) { - return { chartOptionError: error }; - } - }, [option, parseError]); - const rows = useMemo(() => getRows(sanitizedOption), [sanitizedOption]); - const columns = useMemo(() => getColumns(sanitizedOption), [sanitizedOption]); - const chartOption = useMemo( - () => - sanitizedOption ? styleOptionForChart(sanitizedOption, theme) : undefined, - [sanitizedOption, theme], - ); - const hasLoadEcharts = !!loadEcharts; - loadEchartsRef.current = loadEcharts; - tRef.current = t; - - const disposeChart = useCallback(() => { - removeResizeRef.current(); - removeResizeRef.current = noop; - chartInstanceRef.current?.dispose(); - chartInstanceRef.current = undefined; - chartThemeRef.current = undefined; - }, []); - - useEffect( - () => () => { - renderRequestRef.current += 1; - disposeChart(); - }, - [disposeChart], - ); - - useEffect(() => { - if (mode !== 'chart' || parseError || (!chartOption && !chartOptionError)) { - renderRequestRef.current += 1; - disposeChart(); - setChartReady(false); - setChartError(null); - return; - } - const requestId = (renderRequestRef.current += 1); - if (chartOptionError) { - disposeChart(); - setChartReady(false); - console.error( - '[web-shell] echarts-fulldata render failed:', - chartOptionError, - ); - setChartError( - chartOptionError instanceof Error - ? chartOptionError.message - : tRef.current('echartsChart.renderFailed'), - ); - return; - } - if (!chartOption) { - disposeChart(); - setChartReady(false); - return; - } - if (!hasLoadEcharts) { - disposeChart(); - setChartReady(false); - setChartError(tRef.current('echartsChart.runtimeUnavailable')); - return; - } - - setChartError(null); - if (!chartInstanceRef.current) setChartReady(false); - - const renderChart = async () => { - try { - let chart = chartInstanceRef.current; - if (chart && chartThemeRef.current !== theme) { - disposeChart(); - setChartReady(false); - chart = undefined; - } - if (!chart) { - const loadRuntime = loadEchartsRef.current; - if (!loadRuntime || !chartRef.current) return; - const runtime = await loadEchartsWithTimeout(loadRuntime); - if (requestId !== renderRequestRef.current || !chartRef.current) { - return; - } - chart = runtime.init(chartRef.current, theme); - chartInstanceRef.current = chart; - chartThemeRef.current = theme; - } - - if (requestId !== renderRequestRef.current) return; - chart.setOption(chartOption, { notMerge: true }); - if (removeResizeRef.current === noop && chartRef.current) { - let resizeFrame: number | undefined; - const onResize = () => { - if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame); - resizeFrame = requestAnimationFrame(() => { - resizeFrame = undefined; - chartInstanceRef.current?.resize(); - }); - }; - const observer = - typeof ResizeObserver === 'undefined' - ? undefined - : new ResizeObserver(onResize); - if (observer) { - observer.observe(chartRef.current); - } else { - window.addEventListener('resize', onResize); - } - removeResizeRef.current = () => { - if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame); - observer?.disconnect(); - if (!observer) window.removeEventListener('resize', onResize); - }; - } - setChartReady(true); - } catch (error) { - if (requestId !== renderRequestRef.current) return; - console.error('[web-shell] echarts-fulldata render failed:', error); - disposeChart(); - setChartReady(false); - setChartError( - error instanceof Error - ? error.message - : tRef.current('echartsChart.renderFailed'), - ); - } - }; - - void renderChart(); - }, [ - chartOption, - chartOptionError, - disposeChart, - hasLoadEcharts, - mode, - parseError, - theme, - ]); - - const title = getTitle(option) ?? t('echartsChart.defaultTitle'); - - return ( -
-
-
- {title} -
- {!parseError && ( -
- - -
- )} -
- {parseError && isStreaming ? ( - - ) : parseError ? ( -
{parseError}
- ) : mode === 'chart' ? ( -
-
- {chartError ? ( -
-
{chartError}
-
- ) : ( - !chartReady && ( -
- -
- ) - )} -
- ) : ( - - )} -
- ); -} diff --git a/packages/web-shell/client/components/messages/Markdown.test.ts b/packages/web-shell/client/components/messages/Markdown.test.ts index 587941fe0f8..01427c28381 100644 --- a/packages/web-shell/client/components/messages/Markdown.test.ts +++ b/packages/web-shell/client/components/messages/Markdown.test.ts @@ -488,6 +488,7 @@ describe('Markdown custom code block rendering', () => { className: 'language-echarts-fulldata', code: 'const option = {};', isStreaming: true, + isIncomplete: false, source: 'assistant', theme: 'dark', }); @@ -503,6 +504,41 @@ describe('Markdown custom code block rendering', () => { container.remove(); }); + it('marks only the unterminated tail fence as incomplete', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const renderCodeBlock = vi.fn(() => + createElement('div', { 'data-custom-code': 'true' }), + ); + + await act(async () => { + root.render( + createElement( + WebShellCustomizationProvider, + { value: { markdown: { renderCodeBlock } } }, + createElement(Markdown, { + content: + '```first-chart\n{"value":1}\n```\n\ntext\n\n```second-chart\n{"value":', + source: 'assistant', + isStreaming: true, + }), + ), + ); + }); + + const infos = renderCodeBlock.mock.calls.map( + ([info]) => info as WebShellCodeBlockRenderInfo, + ); + expect(infos.map((info) => info.isStreaming)).toEqual([true, true]); + expect(infos.map((info) => info.isIncomplete)).toEqual([false, true]); + + 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); diff --git a/packages/web-shell/client/components/messages/Markdown.tsx b/packages/web-shell/client/components/messages/Markdown.tsx index 8d78e47b286..846652a384b 100644 --- a/packages/web-shell/client/components/messages/Markdown.tsx +++ b/packages/web-shell/client/components/messages/Markdown.tsx @@ -13,6 +13,7 @@ import { useTheme } from '../../themeContext'; import { useTranscriptRenderMode } from '../../transcriptRenderMode'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import type { Components } from 'react-markdown'; +import { isMarkdownFenceClosed } from '@datafe-open/markdown-chart'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; @@ -30,6 +31,11 @@ import { } from '../../customization'; import { ErrorBoundary } from '../ErrorBoundary'; import { EnhancedMarkdownTable } from './EnhancedMarkdownTable'; +import { + DEFAULT_WEB_SHELL_MARKDOWN_CHART, + WebShellMarkdownChartProvider, + createWebShellMarkdownChartPre, +} from './MarkdownChartRenderer'; import styles from './Markdown.module.css'; interface MarkdownProps { @@ -549,22 +555,52 @@ const IsStreamingContext = createContext(false); const MarkdownSourceContext = createContext( undefined, ); +const MarkdownDocumentContext = createContext(undefined); + +interface PositionedCodeNode { + readonly position?: { + readonly start: { readonly offset?: number }; + readonly end: { readonly offset?: number }; + }; +} + +function isIncompleteTailFence( + document: string | undefined, + node: PositionedCodeNode | undefined, + isStreaming: boolean, +): boolean { + if (!isStreaming || document === undefined) return false; + const start = node?.position?.start.offset; + const end = node?.position?.end.offset; + if (start === undefined || end === undefined) return false; + return ( + !isMarkdownFenceClosed(document.slice(start, end)) && + document.slice(end).trim().length === 0 + ); +} function MarkdownCode({ className, children, + node, }: { className?: string; children?: ReactNode; + node?: PositionedCodeNode; }) { const isStreaming = useContext(IsStreamingContext); + const document = useContext(MarkdownDocumentContext); const isBlock = className?.startsWith('language-') || (typeof children === 'string' && children.includes('\n')); if (isBlock) { return ( - + {children} ); @@ -576,10 +612,12 @@ function MarkdownFencedCode({ className, children, isStreaming, + isIncomplete, }: { className?: string; children?: ReactNode; isStreaming?: boolean; + isIncomplete?: boolean; }) { const source = useContext(MarkdownSourceContext); const appTheme = useTheme(); @@ -603,6 +641,7 @@ function MarkdownFencedCode({ className, code, isStreaming: !!isStreaming, + isIncomplete: !!isIncomplete, source, theme: appTheme, }); @@ -616,6 +655,7 @@ function MarkdownFencedCode({ source, appTheme, isStreaming ? 'streaming' : 'settled', + isIncomplete ? 'incomplete' : 'complete', code, ]} > @@ -750,6 +790,7 @@ export const Markdown = memo(function Markdown({ tableMode, }: MarkdownProps) { const { markdown, markdownTableMode } = useWebShellCustomization(); + const theme = useTheme(); const sourceMarkdown = source ? markdown : undefined; const renderedContent = content && source && sourceMarkdown?.transformMarkdown @@ -773,6 +814,33 @@ export const Markdown = memo(function Markdown({ ...(effectiveTableMode === 'advanced' ? { table: components.table } : {}), }; }, [components, effectiveTableMode, sourceComponents]); + const chart = + source === 'assistant' && !sourceComponents?.code && !sourceComponents?.pre + ? (sourceMarkdown?.chart ?? + (sourceMarkdown?.renderCodeBlock + ? undefined + : DEFAULT_WEB_SHELL_MARKDOWN_CHART)) + : undefined; + const chartPre = useMemo( + () => + chart + ? createWebShellMarkdownChartPre(chart.registry, { + chartClassName: chart.chartClassName, + chartStyle: { minHeight: 360, ...chart.chartStyle }, + }) + : undefined, + [chart], + ); + const componentsWithCharts = useMemo( + () => + chartPre + ? { + ...renderedComponents, + pre: chartPre, + } + : renderedComponents, + [chartPre, renderedComponents], + ); if (!content) return null; const remarkPlugins = sourceMarkdown?.remarkPlugins @@ -782,6 +850,29 @@ export const Markdown = memo(function Markdown({ ? [rehypeKatex, ...sourceMarkdown.rehypePlugins] : [rehypeKatex]; + const renderedMarkdown = ( + + {renderedContent} + + ); + const chartAwareMarkdown = chart ? ( + + {renderedMarkdown} + + ) : ( + renderedMarkdown + ); + return (
- - {renderedContent} - + + {chartAwareMarkdown} +
diff --git a/packages/web-shell/client/components/messages/MarkdownChartRenderer.test.tsx b/packages/web-shell/client/components/messages/MarkdownChartRenderer.test.tsx new file mode 100644 index 00000000000..6ea1a95b661 --- /dev/null +++ b/packages/web-shell/client/components/messages/MarkdownChartRenderer.test.tsx @@ -0,0 +1,1008 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebShellCustomizationProvider } from '../../customization'; +import { I18nProvider } from '../../i18n'; +import { ThemeProvider } from '../../themeContext'; +import { Markdown } from './Markdown'; +import { + EchartsFullDataBlock, + createEchartsFullDataRenderer, + createMarkdownChartRegistry, + type EchartsFullDataRefResolver, + type EchartsRuntime, +} from './MarkdownChartRenderer'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +async function mount(node: ReactNode): Promise<{ + container: HTMLElement; + rerender(next: ReactNode): Promise; +}> { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(node); + }); + mounted.push({ root, container }); + return { + container, + async rerender(next) { + await act(async () => { + root.render(next); + }); + }, + }; +} + +async function flushChart(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function createFakeRuntime() { + const instance = { + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + }; + const runtime = { + init: vi.fn(() => instance), + }; + return { instance, runtime }; +} + +function canonicalChart(title = 'Weekly orders'): string { + return JSON.stringify({ + version: 1, + renderer: 'echarts', + data: { + kind: 'inline', + dimensions: ['day', 'orders'], + source: [ + ['Mon', 120], + ['Tue', 200], + ], + }, + spec: { + title: { text: title }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }, + }); +} + +function chartTree({ + content, + registry, + isStreaming = false, + source = 'assistant', +}: { + content: string; + registry: ReturnType; + isStreaming?: boolean; + source?: 'assistant' | 'thinking'; +}) { + return ( + + + + + + + + ); +} + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(async () => { + for (const { root, container } of mounted.splice(0)) { + await act(async () => { + root.unmount(); + }); + container.remove(); + } + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('Web Shell markdown-chart integration', () => { + it('enables the built-in registry without host chart configuration', async () => { + const chart = canonicalChart(); + const { container } = await mount( + + + + + + + , + ); + + expect( + container.querySelector('[data-markdown-chart-loading="true"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('Rendering chart'); + }); + + it('renders canonical charts and the shared Chart/Data view', async () => { + const { instance, runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const { container } = await mount( + chartTree({ + content: `\`\`\`markdown-chart\n${canonicalChart()}\n\`\`\``, + registry, + }), + ); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(instance.setOption).toHaveBeenCalledOnce(); + expect(container.querySelector('.markdown-chart-card')).not.toBeNull(); + expect(container.textContent).toContain('Weekly orders'); + + const showData = container.querySelector( + 'button[aria-label="Show data"]', + ); + expect(showData).not.toBeNull(); + await act(async () => { + showData?.click(); + }); + expect( + container.querySelector('[data-markdown-chart-data-view="true"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('Tue'); + expect(container.textContent).toContain('200'); + }); + + it('keeps tooltip safety invariants in the bundled ECharts renderer', async () => { + const { instance, runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const chart = JSON.stringify({ + version: 1, + renderer: 'echarts', + spec: { + tooltip: { + appendToBody: true, + confine: false, + enterable: true, + renderMode: 'html', + }, + series: [ + { + type: 'bar', + tooltip: { + appendToBody: true, + confine: false, + enterable: true, + renderMode: 'html', + }, + }, + ], + }, + }); + await mount( + chartTree({ + content: `\`\`\`markdown-chart\n${chart}\n\`\`\``, + registry, + }), + ); + await flushChart(); + + const option = instance.setOption.mock.calls[0]?.[0]; + expect(option).toMatchObject({ + tooltip: { + appendToBody: false, + confine: true, + enterable: false, + renderMode: 'richText', + }, + series: [ + { + tooltip: { + appendToBody: false, + confine: true, + enterable: false, + renderMode: 'richText', + }, + }, + ], + }); + }); + + it('rejects unsafe chart option fields before calling ECharts', async () => { + const prototypeSeries: Record = { type: 'bar' }; + Object.defineProperty(prototypeSeries, '__proto__', { + value: { polluted: true }, + enumerable: true, + }); + const unsafeSpecs = [ + { + tooltip: { formatter: '' }, + series: [{ type: 'bar' }], + }, + { + color: ['javascript:alert(1)'], + series: [{ type: 'bar' }], + }, + { + series: [ + { + type: 'bar', + cursor: 'url(https://example.test/ping), auto', + href: 'javascript:alert(1)', + symbol: 'image://https://example.test/marker.png', + }, + ], + }, + { series: [prototypeSeries] }, + ]; + + for (const spec of unsafeSpecs) { + const { runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const chart = JSON.stringify({ + version: 1, + renderer: 'echarts', + spec, + }); + const { container } = await mount( + chartTree({ + content: `\`\`\`markdown-chart\n${chart}\n\`\`\``, + registry, + }), + ); + await flushChart(); + + expect(runtime.init).not.toHaveBeenCalled(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Chart render failed.', + ); + expect(container.querySelector('pre code')).toBeNull(); + } + }); + + it('localizes shared chart controls and preserves host label overrides', async () => { + const { runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const customization = { + markdown: { + chart: { + registry, + labels: { + data: '数据明细', + showData: '显示数据明细', + }, + }, + }, + }; + const { container } = await mount( + + + + + + + , + ); + await flushChart(); + + expect( + container + .querySelector('.markdown-chart-toggle') + ?.getAttribute('aria-label'), + ).toBe('视图模式'); + expect( + container + .querySelector('[data-markdown-chart-chart-view="true"]') + ?.getAttribute('aria-label'), + ).toBe('图表'); + expect( + container.querySelector( + 'button[aria-label="显示数据明细"]', + )?.title, + ).toBe('数据明细'); + }); + + it('renders a closed chart immediately while later Markdown is streaming', async () => { + const { instance, runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const chart = `\`\`\`markdown-chart\n${canonicalChart()}\n\`\`\``; + const customization = { markdown: { chart: { registry } } }; + const tree = (content: string) => ( + + + + + + + + ); + const mountedChart = await mount(tree(`${chart}\n\nFirst token`)); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(instance.setOption).toHaveBeenCalledOnce(); + + await mountedChart.rerender(tree(`${chart}\n\nFirst token, then more`)); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(instance.setOption).toHaveBeenCalledOnce(); + expect(instance.dispose).not.toHaveBeenCalled(); + }); + + it('renders a closed blockquote chart while the response is streaming', async () => { + const { runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const quotedChart = `\`\`\`markdown-chart\n${canonicalChart()}\n\`\`\`` + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); + const { container } = await mount( + chartTree({ + content: quotedChart, + registry, + isStreaming: true, + }), + ); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(container.querySelector('.markdown-chart-streaming')).toBeNull(); + expect(container.querySelector('.markdown-chart-card')).not.toBeNull(); + }); + + it('loads only after the active tail fence closes', async () => { + const { runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const customization = { markdown: { chart: { registry } } }; + const tree = (content: string) => ( + + + + + + + + ); + const chart = canonicalChart(); + const mountedChart = await mount( + tree(`\`\`\`markdown-chart\n${chart.slice(0, -2)}`), + ); + await flushChart(); + + expect(runtime.init).not.toHaveBeenCalled(); + expect( + mountedChart.container.querySelector( + '[data-markdown-chart-loading="true"]', + ), + ).not.toBeNull(); + expect(mountedChart.container.textContent).toContain('Rendering chart'); + + await mountedChart.rerender(tree(`\`\`\`markdown-chart\n${chart}\n\`\`\``)); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect( + mountedChart.container.querySelector('.markdown-chart-card'), + ).not.toBeNull(); + }); + + it('leaves chart fences as code in thinking content', async () => { + const { runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const { container } = await mount( + chartTree({ + content: `\`\`\`markdown-chart\n${canonicalChart()}\n\`\`\``, + registry, + source: 'thinking', + }), + ); + + expect(runtime.init).not.toHaveBeenCalled(); + expect(container.querySelector('pre code')).not.toBeNull(); + expect(container.textContent).toContain('markdown-chart'); + }); + + it('lets explicit custom code components keep precedence', async () => { + const { runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const { container } = await mount( + + ( + {children} + ), + }, + }, + }} + > + + + , + ); + + expect(runtime.init).not.toHaveBeenCalled(); + expect(container.querySelector('[data-host-code="true"]')).not.toBeNull(); + }); + + it('reports invalid chart specs through the shared safe error state', async () => { + const { runtime } = createFakeRuntime(); + const onError = vi.fn(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + const invalid = JSON.stringify({ + version: 1, + renderer: 'echarts', + spec: { graphic: { type: 'image', src: 'https://example.com/a.png' } }, + }); + const { container } = await mount( + + + + + , + ); + await flushChart(); + + expect(runtime.init).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Chart render failed.', + ); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('rejects unsafe refs before calling the host resolver by default', async () => { + const { runtime } = createFakeRuntime(); + const resolveDataRef = vi.fn(async () => ({ + dimensions: ['day', 'orders'], + source: [['Mon', 120]] as const, + })); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resolveDataRef, + resizeObserver: false, + }); + const compact = (ref: string) => + JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref, + format: 'csv', + dimensions: ['day', 'orders'], + }, + option: { series: [{ type: 'bar' }] }, + }); + const tree = (ref: string) => + chartTree({ + content: `\`\`\`echarts-fulldata\n${compact(ref)}\n\`\`\``, + registry, + }); + const chart = await mount(tree('ARTIFACT://charts/%6Frders.csv')); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledWith( + 'artifact://charts/orders.csv', + expect.objectContaining({ + dimensions: ['day', 'orders'], + format: 'csv', + }), + ); + + await chart.rerender(tree('artifact://charts/%2e%2e/secret.csv')); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledOnce(); + expect(chart.container.querySelector('[role="alert"]')).not.toBeNull(); + }); + + it('times out a host data resolver on the primary registry path', async () => { + vi.useFakeTimers(); + const { runtime } = createFakeRuntime(); + const resolveDataRef = vi.fn( + () => + new Promise<{ + dimensions: string[]; + source: Array>; + }>(() => {}), + ); + const onError = vi.fn(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resolveDataRef, + resizeObserver: false, + }); + const chart = JSON.stringify({ + version: 1, + renderer: 'echarts', + data: { + kind: 'ref', + ref: 'artifact://charts/orders.csv', + format: 'csv', + dimensions: ['day', 'orders'], + }, + spec: { series: [{ type: 'bar' }] }, + }); + const { container } = await mount( + + + + + , + ); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledOnce(); + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + await flushChart(); + + expect(onError).toHaveBeenCalledOnce(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Chart render failed.', + ); + }); + + it('disposes the shared chart lifecycle on unmount', async () => { + const { instance, runtime } = createFakeRuntime(); + const registry = createMarkdownChartRegistry({ + loadECharts: async () => runtime, + resizeObserver: false, + }); + await mount( + chartTree({ + content: `\`\`\`markdown-chart\n${canonicalChart()}\n\`\`\``, + registry, + }), + ); + await flushChart(); + + expect(instance.dispose).not.toHaveBeenCalled(); + const [{ root, container }] = mounted.splice(0); + await act(async () => { + root.unmount(); + }); + container.remove(); + expect(instance.dispose).toHaveBeenCalledOnce(); + }); +}); + +describe('deprecated echarts-fulldata compatibility adapter', () => { + it('keeps an incomplete legacy fence in the loading state', async () => { + const { runtime } = createFakeRuntime(); + const renderer = createEchartsFullDataRenderer({ + loadEcharts: async () => runtime as EchartsRuntime, + }); + const { container } = await mount( + + + + + , + ); + await flushChart(); + + expect( + container.querySelector('[data-markdown-chart-loading="true"]'), + ).not.toBeNull(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.querySelector('pre code')).toBeNull(); + expect(runtime.init).not.toHaveBeenCalled(); + }); + + it('matches mixed-case legacy fence languages', async () => { + const { runtime } = createFakeRuntime(); + const renderer = createEchartsFullDataRenderer({ + loadEcharts: async () => runtime as EchartsRuntime, + }); + const { container } = await mount( + + + + + , + ); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect( + container.querySelector('.markdown-chart-placeholder'), + ).not.toBeNull(); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('keeps legacy plain ECharts option bodies renderable', async () => { + const { instance, runtime } = createFakeRuntime(); + const renderer = createEchartsFullDataRenderer({ + loadEcharts: async () => runtime as EchartsRuntime, + }); + const option = { + title: { text: 'Legacy option' }, + dataset: { + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + }, + series: [{ type: 'bar' }], + }; + const { container } = await mount( + + + + + , + ); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(instance.setOption).toHaveBeenCalledWith( + expect.objectContaining({ + dataset: expect.objectContaining({ + dimensions: ['day', 'orders'], + }), + }), + expect.anything(), + ); + expect( + container.querySelector('.markdown-chart-placeholder'), + ).not.toBeNull(); + }); + + it('delegates compact inline envelopes to markdown-chart', async () => { + const { runtime } = createFakeRuntime(); + const renderer = createEchartsFullDataRenderer({ + loadEcharts: async () => runtime as EchartsRuntime, + }); + const compact = JSON.stringify({ + version: 1, + data: { + kind: 'inline', + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + }, + option: { + xAxis: { type: 'category' }, + yAxis: {}, + series: [{ type: 'bar' }], + }, + }); + const { container } = await mount( + + + + + , + ); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(container.querySelector('.markdown-chart-card')).not.toBeNull(); + }); + + it('preserves the missing-runtime error for legacy hosts without a loader', async () => { + const renderer = createEchartsFullDataRenderer(); + const { container } = await mount( + + + + + , + ); + await flushChart(); + + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Chart render failed.', + ); + }); + + it('preserves the controlled ref allowlist and legacy resolver metadata', async () => { + const { runtime } = createFakeRuntime(); + const resolveDataRef = vi.fn(async () => ({ + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + })); + const renderer = createEchartsFullDataRenderer({ + loadEcharts: async () => runtime as EchartsRuntime, + resolveDataRef, + }); + const compact = (ref: string) => + JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref, + format: 'csv', + dimensions: ['day', 'orders'], + }, + option: { series: [{ type: 'bar' }] }, + }); + const customization = { + markdown: { renderCodeBlock: renderer }, + }; + const tree = (ref: string) => ( + + + + + + ); + const chart = await mount(tree('ARTIFACT://charts/%6Frders.csv')); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledWith( + 'artifact://charts/orders.csv', + { + dimensions: ['day', 'orders'], + format: 'csv', + }, + ); + expect(runtime.init).toHaveBeenCalledOnce(); + + for (const invalidRef of [ + 'https://example.com/orders.csv', + 'artifact://charts/../secret.csv', + 'artifact://charts/%2e%2e/secret.csv', + 'session-file://C:/secret.csv', + 'artifact://charts/orders.csv?download=1', + ]) { + await chart.rerender(tree(invalidRef)); + await flushChart(); + } + + expect(resolveDataRef).toHaveBeenCalledOnce(); + expect(chart.container.querySelector('[role="alert"]')?.textContent).toBe( + 'Chart render failed.', + ); + }); + + it('does not resolve the same legacy ref again when response streaming ends', async () => { + const { runtime } = createFakeRuntime(); + const resolveDataRef = vi.fn(async () => ({ + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + })); + const renderer = createEchartsFullDataRenderer({ + loadEcharts: async () => runtime as EchartsRuntime, + resolveDataRef, + }); + const compact = JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://charts/orders.csv', + format: 'csv', + dimensions: ['day', 'orders'], + }, + option: { series: [{ type: 'bar' }] }, + }); + const customization = { markdown: { renderCodeBlock: renderer } }; + const tree = (isStreaming: boolean) => ( + + + + + + ); + const chart = await mount(tree(false)); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledOnce(); + expect(runtime.init).toHaveBeenCalledOnce(); + + await chart.rerender(tree(true)); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledOnce(); + + await chart.rerender(tree(false)); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledOnce(); + }); + + it('keeps the direct component export as a thin shared-renderer wrapper', async () => { + const { runtime } = createFakeRuntime(); + const { container } = await mount( + + runtime as EchartsRuntime} + /> + , + ); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(runtime.init).toHaveBeenCalledWith(expect.any(HTMLElement), 'dark'); + expect( + container.querySelector('.markdown-chart-placeholder'), + ).not.toBeNull(); + }); + + it('keeps the direct chart mounted when the loader prop identity changes', async () => { + const { instance, runtime } = createFakeRuntime(); + const option = { + dataset: { + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + }, + series: [{ type: 'bar' }], + }; + const tree = (nonce: number) => ( + +
+ runtime as EchartsRuntime} + /> +
+
+ ); + const chart = await mount(tree(1)); + await flushChart(); + + await chart.rerender(tree(2)); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(instance.setOption).toHaveBeenCalledOnce(); + expect(instance.dispose).not.toHaveBeenCalled(); + }); + + it('keeps the direct component parseError prop observable', async () => { + const { container } = await mount( + + + , + ); + + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Invalid legacy chart', + ); + }); + + it('keeps a direct parse error loading while streaming and reveals it when settled', async () => { + const tree = (isStreaming: boolean) => ( + + + + ); + const chart = await mount(tree(true)); + + expect( + chart.container.querySelector('[data-markdown-chart-loading="true"]'), + ).not.toBeNull(); + expect(chart.container.textContent).toContain('Rendering chart'); + expect(chart.container.querySelector('[role="alert"]')).toBeNull(); + + await chart.rerender(tree(false)); + + expect( + chart.container.querySelector('[data-markdown-chart-loading="true"]'), + ).toBeNull(); + expect(chart.container.querySelector('[role="alert"]')?.textContent).toBe( + 'Invalid legacy chart', + ); + }); +}); diff --git a/packages/web-shell/client/components/messages/MarkdownChartRenderer.tsx b/packages/web-shell/client/components/messages/MarkdownChartRenderer.tsx new file mode 100644 index 00000000000..a226eaf31be --- /dev/null +++ b/packages/web-shell/client/components/messages/MarkdownChartRenderer.tsx @@ -0,0 +1,531 @@ +import { + Fragment, + createElement, + isValidElement, + useMemo, + useRef, + type CSSProperties, + type ReactElement, + type ReactNode, +} from 'react'; +import { + ChartRendererRegistry, + type JsonPrimitive, + type JsonValue, + type MarkdownChartLabelOverrides, +} from '@datafe-open/markdown-chart'; +import { + createEChartsRenderer, + type CreateEChartsRendererOptions, + type LoadedEChartsRuntime, + type ResolveDataRef, +} from '@datafe-open/markdown-chart-echarts'; +import { + MarkdownChartBlock, + MarkdownChartProvider, + createMarkdownChartComponents, + isRegisteredChartLanguage, +} from '@datafe-open/markdown-chart-react'; +import type { Components } from 'react-markdown'; +import type { + CodeBlockRenderer, + WebShellCodeBlockRenderInfo, + WebShellMarkdownChartCustomization, +} from '../../customization'; +import { useI18n } from '../../i18n'; +import type { WebShellTheme } from '../../themeContext'; + +export const ECHARTS_FULLDATA_LANGUAGE = 'echarts-fulldata'; + +const DATA_REF_TIMEOUT_MS = 30_000; +const SUPPORTED_DATA_REF_PREFIXES = ['artifact://', 'session-file://']; +const WINDOWS_DRIVE_SEGMENT_PATTERN = /^[a-z]:/i; + +export type DatasetCell = JsonPrimitive; + +export interface EchartsFullDataOption { + readonly [key: string]: JsonValue | undefined; +} + +export interface EchartsInstance { + setOption( + option: EchartsFullDataOption, + opts?: { readonly notMerge?: boolean }, + ): void; + resize(): void; + dispose(): void; +} + +export interface EchartsRuntime { + init(element: HTMLElement, theme?: string): EchartsInstance; +} + +export type EchartsRuntimeLoader = () => + | EchartsRuntime + | Promise; + +export interface EchartsFullDataResolvedDataset { + readonly dimensions: string[]; + readonly source: DatasetCell[][]; +} + +export interface EchartsFullDataRefMeta { + readonly dimensions: string[]; + readonly format: 'csv' | 'json'; +} + +export type EchartsFullDataRefResolver = ( + ref: string, + meta: EchartsFullDataRefMeta, +) => EchartsFullDataResolvedDataset | Promise; + +export interface EchartsFullDataBlockProps { + readonly option?: EchartsFullDataOption; + readonly parseError?: string; + readonly isStreaming?: boolean; + readonly theme: WebShellTheme; + readonly loadEcharts?: EchartsRuntimeLoader; +} + +export interface EchartsFullDataRendererOptions { + readonly loadEcharts?: EchartsRuntimeLoader; + readonly resolveDataRef?: EchartsFullDataRefResolver; +} + +export function createMarkdownChartRegistry( + options: CreateEChartsRendererOptions = {}, +): ChartRendererRegistry { + const { + resolveDataRef, + validateDataRef = isSupportedLegacyDataRef, + ...rendererOptions + } = options; + const usesDefaultDataRefValidation = options.validateDataRef === undefined; + const boundedResolveDataRef: ResolveDataRef | undefined = resolveDataRef + ? async (ref, context) => { + const resolvedRef = usesDefaultDataRefValidation + ? normalizeSupportedDataRef(ref) + : ref; + if (!resolvedRef) { + throw new Error('Chart data reference is not supported.'); + } + return withTimeout( + Promise.resolve(resolveDataRef(resolvedRef, context)), + 'Chart data reference resolution', + ); + } + : undefined; + return new ChartRendererRegistry().register( + createEChartsRenderer({ + ...rendererOptions, + resolveDataRef: boundedResolveDataRef, + validateDataRef, + }), + ); +} + +export const DEFAULT_WEB_SHELL_MARKDOWN_CHART: WebShellMarkdownChartCustomization = + { + registry: createMarkdownChartRegistry(), + }; + +interface ChartPreChildProps { + readonly className?: string; + readonly children?: ReactNode; +} + +export function createWebShellMarkdownChartPre( + registry: ChartRendererRegistry, + options: { + readonly chartClassName?: string; + readonly chartStyle?: CSSProperties; + } = {}, +): NonNullable { + const sharedPre = createMarkdownChartComponents(options).pre; + if (!sharedPre) { + throw new Error( + 'markdown-chart React adapter did not provide a pre renderer', + ); + } + return function WebShellMarkdownChartPre({ children, ...props }) { + if (isValidElement(children)) { + const code = children; + const match = /(?:^|\s)language-([^\s]+)/.exec( + code.props.className ?? '', + ); + const language = match?.[1]?.toLowerCase(); + if (language && isRegisteredChartLanguage(language, registry)) { + return createElement(sharedPre, props, children); + } + } + return createElement(Fragment, null, children); + }; +} + +export function WebShellMarkdownChartProvider({ + customization, + source, + streaming, + theme, + children, +}: { + readonly customization: WebShellMarkdownChartCustomization; + readonly source: string; + readonly streaming: boolean; + readonly theme: WebShellTheme; + readonly children: ReactNode; +}): ReactElement { + const labels = useWebShellMarkdownChartLabels(customization.labels); + const { t } = useI18n(); + return ( + + {children} + + ); +} + +function useWebShellMarkdownChartLabels( + overrides?: MarkdownChartLabelOverrides, +): MarkdownChartLabelOverrides { + const { t } = useI18n(); + return useMemo( + () => ({ + chartUnavailable: t('echartsChart.renderFailed'), + viewMode: t('echartsChart.viewMode'), + chart: t('echartsChart.chart'), + data: t('echartsChart.data'), + showChart: t('echartsChart.showChart'), + showData: t('echartsChart.showData'), + noData: t('echartsChart.noData'), + tableNotice: ({ visibleRows, totalRows, visibleColumns, totalColumns }) => + t('echartsChart.tableNotice', { + visibleRows, + totalRows, + visibleColumns, + totalColumns, + omittedRows: totalRows - visibleRows, + omittedColumns: totalColumns - visibleColumns, + }), + ...overrides, + }), + [overrides, t], + ); +} + +function hasWhitespaceOrControl(value: string): boolean { + return ( + /\s/.test(value) || + Array.from(value).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 31 || codePoint === 127); + }) + ); +} + +function normalizeSupportedDataRef(ref: string): string | undefined { + if (ref.trim() !== ref || !ref || hasWhitespaceOrControl(ref)) { + return undefined; + } + const lower = ref.toLowerCase(); + const prefix = SUPPORTED_DATA_REF_PREFIXES.find((candidate) => + lower.startsWith(candidate), + ); + if (!prefix) { + return undefined; + } + const rawPath = ref.slice(prefix.length); + if (!rawPath || /[?#\\]/.test(rawPath)) { + return undefined; + } + let decodedPath: string; + try { + decodedPath = decodeURIComponent(rawPath); + } catch { + return undefined; + } + if (/[%?#\\]/.test(decodedPath) || hasWhitespaceOrControl(decodedPath)) { + return undefined; + } + const segments = decodedPath.split('/'); + if ( + segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + WINDOWS_DRIVE_SEGMENT_PATTERN.test(segment), + ) + ) { + return undefined; + } + return `${prefix}${segments.join('/')}`; +} + +function isSupportedLegacyDataRef(ref: string): boolean { + return normalizeSupportedDataRef(ref) !== undefined; +} + +function withTimeout(promise: Promise, label: string): Promise { + return new Promise((resolve, reject) => { + const timeout = globalThis.setTimeout( + () => reject(new Error(`${label} timed out.`)), + DATA_REF_TIMEOUT_MS, + ); + promise + .then(resolve, reject) + .finally(() => globalThis.clearTimeout(timeout)); + }); +} + +function adaptLegacyRuntimeLoader( + loadEcharts: EchartsRuntimeLoader | undefined, +): CreateEChartsRendererOptions['loadECharts'] { + if (!loadEcharts) { + return async () => { + throw new Error('Chart runtime is unavailable.'); + }; + } + return async () => { + const runtime = await withTimeout( + Promise.resolve().then(loadEcharts), + 'Chart runtime load', + ); + return { + init(container: HTMLElement, theme?: string | object | null) { + return runtime.init( + container, + typeof theme === 'string' ? theme : undefined, + ); + }, + } as LoadedEChartsRuntime; + }; +} + +function adaptLegacyDataResolver( + resolveDataRef: EchartsFullDataRefResolver | undefined, +): ResolveDataRef | undefined { + if (!resolveDataRef) { + return undefined; + } + let cached: + | { + readonly key: string; + readonly dataset: EchartsFullDataResolvedDataset; + } + | undefined; + return async (ref, context) => { + if (!context.dimensions || !context.format) { + throw new Error('Chart data reference metadata is incomplete.'); + } + const normalizedRef = normalizeSupportedDataRef(ref); + if (!normalizedRef) { + throw new Error('Chart data reference is not supported.'); + } + const key = JSON.stringify([ + normalizedRef, + context.format, + context.dimensions, + ]); + if (cached?.key === key) { + return cached.dataset; + } + const resolved = await resolveDataRef(normalizedRef, { + dimensions: [...context.dimensions], + format: context.format, + }); + const dataset = { + dimensions: resolved.dimensions, + source: resolved.source, + }; + cached = { key, dataset }; + return dataset; + }; +} + +function createLegacyRegistry( + options: EchartsFullDataRendererOptions, +): ChartRendererRegistry { + return createMarkdownChartRegistry({ + loadECharts: adaptLegacyRuntimeLoader(options.loadEcharts), + resolveDataRef: adaptLegacyDataResolver(options.resolveDataRef), + validateDataRef: isSupportedLegacyDataRef, + }); +} + +function normalizeLegacyEchartsSource(source: string): { + readonly language: string; + readonly source: string; +} { + try { + const parsed = JSON.parse(source) as unknown; + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + !('version' in parsed && 'data' in parsed && 'option' in parsed) + ) { + return { + language: 'markdown-chart', + source: JSON.stringify({ + version: 1, + renderer: 'echarts', + spec: parsed, + }), + }; + } + } catch { + // Let the shared strict parser produce the user-facing error. + } + return { language: ECHARTS_FULLDATA_LANGUAGE, source }; +} + +function reportLegacyMarkdownChartError(error: unknown): void { + console.error('[web-shell] markdown-chart render failed:', error); +} + +function MarkdownChartCodeBlock({ + registry, + language, + source, + streaming, + theme, +}: { + readonly registry: ChartRendererRegistry; + readonly language: string; + readonly source: string; + readonly streaming: boolean; + readonly theme: WebShellTheme; +}): ReactElement { + const { t } = useI18n(); + const labels = useWebShellMarkdownChartLabels(); + return ( + + + + ); +} + +function LegacyMarkdownChartCodeBlock({ + options, + language, + source, + streaming, + theme, +}: { + readonly options: EchartsFullDataRendererOptions; + readonly language: string; + readonly source: string; + readonly streaming: boolean; + readonly theme: WebShellTheme; +}): ReactElement { + const registry = useMemo(() => createLegacyRegistry(options), [options]); + return ( + + ); +} + +/** + * @deprecated Configure `markdown.chart.registry` with + * `createMarkdownChartRegistry` instead. + */ +export function createEchartsFullDataRenderer( + options: EchartsFullDataRendererOptions = {}, +): CodeBlockRenderer { + return function renderEchartsFullDataBlock( + info: WebShellCodeBlockRenderInfo, + ) { + if ( + info.source !== 'assistant' || + info.language.toLowerCase() !== ECHARTS_FULLDATA_LANGUAGE + ) { + return undefined; + } + const normalized = info.isIncomplete + ? { language: ECHARTS_FULLDATA_LANGUAGE, source: info.code } + : normalizeLegacyEchartsSource(info.code); + return ( + + ); + }; +} + +/** + * @deprecated Configure `markdown.chart.registry` and emit a Markdown chart + * fence instead. + */ +export function EchartsFullDataBlock({ + option, + parseError, + isStreaming = false, + theme, + loadEcharts, +}: EchartsFullDataBlockProps): ReactElement { + const loadEchartsRef = useRef(loadEcharts); + loadEchartsRef.current = loadEcharts; + const hasLoadEcharts = loadEcharts !== undefined; + const stableLoadEcharts = useMemo( + () => + hasLoadEcharts + ? () => { + const current = loadEchartsRef.current; + if (!current) { + throw new Error('Chart runtime is unavailable.'); + } + return current(); + } + : undefined, + [hasLoadEcharts], + ); + const registry = useMemo( + () => createLegacyRegistry({ loadEcharts: stableLoadEcharts }), + [stableLoadEcharts], + ); + if (parseError && !isStreaming) { + return
{parseError}
; + } + return ( + + ); +} diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index c79bfe1e555..082921a1a35 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -1,10 +1,16 @@ import { createContext, useContext, + type CSSProperties, type ComponentType, type ReactNode, } from 'react'; import type { Components, Options } from 'react-markdown'; +import type { + ChartRendererRegistry, + MarkdownChartLabelOverrides, +} from '@datafe-open/markdown-chart'; +import type { MarkdownChartReactErrorHandler } from '@datafe-open/markdown-chart-react'; import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import type { DaemonStreamingState } from '@qwen-code/webui/daemon-react-sdk'; import type { ACPToolCall } from './adapters/types'; @@ -32,6 +38,8 @@ export interface WebShellCodeBlockRenderInfo { code: string; /** True while the assistant message is still streaming partial content. */ isStreaming: boolean; + /** True only when this block is the active unterminated tail fence. */ + isIncomplete: boolean; source: MarkdownContentSource; theme: WebShellTheme; } @@ -39,18 +47,34 @@ export interface WebShellCodeBlockRenderInfo { /** * 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. + * block renderer. Expensive renderers should defer parsing while + * `info.isIncomplete` is true; `info.isStreaming` describes the surrounding + * assistant message and may remain true after this block has closed. */ export type CodeBlockRenderer = ( info: WebShellCodeBlockRenderInfo, ) => ReactNode | null | undefined; +export interface WebShellMarkdownChartCustomization { + registry: ChartRendererRegistry; + loadingLabel?: string; + labels?: MarkdownChartLabelOverrides; + onError?: MarkdownChartReactErrorHandler; + chartClassName?: string; + chartStyle?: CSSProperties; +} + export interface WebShellMarkdownCustomization { transformMarkdown?: ( markdown: string, context: MarkdownRenderContext, ) => string; + /** + * Override Web Shell's built-in Markdown chart registry or its presentation + * callbacks. The complete chart customization object must remain + * referentially stable while a chart is mounted. + */ + chart?: WebShellMarkdownChartCustomization; renderCodeBlock?: CodeBlockRenderer; /** * Custom markdown components override Web Shell's built-ins. In particular, diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 2294fac0c39..e2a6fd09f6a 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -477,7 +477,6 @@ 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); @@ -491,7 +490,6 @@ const EN: Messages = { 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', @@ -2976,7 +2974,6 @@ 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); @@ -2990,7 +2987,6 @@ const ZH: Messages = { return `显示 ${v?.visibleRows ?? 0}/${v?.totalRows ?? 0} 行`; }, 'echartsChart.rendering': '正在渲染图表', - 'echartsChart.runtimeUnavailable': '图表运行时不可用。', 'echartsChart.renderFailed': '图表渲染失败。', 'echartsChart.viewMode': '视图模式', 'echartsChart.showChart': '显示图表', diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 3a5f5cf01af..8e4276f17a1 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -203,6 +203,7 @@ export type { WebShellAtProvider, WebShellBottomStatusItem, WebShellCodeBlockRenderInfo, + WebShellMarkdownChartCustomization, WebShellMarkdownCustomization, WebShellAssistantMessageInfo, WebShellAssistantTurnFooterRenderInfo, @@ -223,8 +224,9 @@ export type { export { ECHARTS_FULLDATA_LANGUAGE, EchartsFullDataBlock, + createMarkdownChartRegistry, createEchartsFullDataRenderer, -} from './components/messages/EchartsFullDataBlock'; +} from './components/messages/MarkdownChartRenderer'; export type { DatasetCell, EchartsFullDataBlockProps, @@ -236,4 +238,4 @@ export type { EchartsInstance, EchartsRuntime, EchartsRuntimeLoader, -} from './components/messages/EchartsFullDataBlock'; +} from './components/messages/MarkdownChartRenderer'; diff --git a/packages/web-shell/docs/examples/qwencode-viz/SKILL.md b/packages/web-shell/docs/examples/qwencode-viz/SKILL.md deleted file mode 100644 index 338b40e980f..00000000000 --- a/packages/web-shell/docs/examples/qwencode-viz/SKILL.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -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. diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json index bf683f9f8ab..1e0bf5c88bd 100644 --- a/packages/web-shell/package.json +++ b/packages/web-shell/package.json @@ -13,8 +13,7 @@ }, "files": [ "dist/index.js", - "dist/types", - "docs/examples/qwencode-viz/SKILL.md" + "dist/types" ], "scripts": { "dev": "vite", @@ -39,10 +38,14 @@ "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.0", + "@datafe-open/markdown-chart": "^0.1.12", + "@datafe-open/markdown-chart-echarts": "^0.1.12", + "@datafe-open/markdown-chart-react": "^0.1.12", "@tanstack/react-virtual": "^3.13.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codemirror": "^6.0.0", + "echarts": "^6.0.0", "fzf": "^0.5.2", "katex": "^0.16.47", "lucide-react": "^1.24.0", diff --git a/packages/web-shell/vite.lib.config.ts b/packages/web-shell/vite.lib.config.ts index a7e920a25d1..fe3518a8821 100644 --- a/packages/web-shell/vite.lib.config.ts +++ b/packages/web-shell/vite.lib.config.ts @@ -158,6 +158,11 @@ export default defineConfig({ /^@qwen-code\/sdk\//, '@qwen-code/webui', /^@qwen-code\/webui\//, + '@datafe-open/markdown-chart', + '@datafe-open/markdown-chart-echarts', + '@datafe-open/markdown-chart-react', + 'echarts', + /^echarts\//, 'react-markdown', 'remark-gfm', 'remark-math',