diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 78628f8c..c9f317d8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -473,8 +473,10 @@ when multiple packages use the same license. - `clsx@2.1.1` — MIT - `cookie@1.1.1` — MIT - `dompurify@3.4.12` — MPL-2.0 or Apache-2.0 +- `fancy-canvas@2.1.0` — MIT - `framer-motion@12.42.2` — MIT - `highlight.js@11.11.1` — BSD-3-Clause +- `lightweight-charts@5.2.0` — Apache-2.0 - `lucide-react@1.25.0` — ISC - `marked@18.0.7` — MIT - `motion@12.42.2` — MIT @@ -494,6 +496,16 @@ The type-only `@types/*` and `csstype` packages installed to compile the TypeScript source do not enter the browser bundle and are intentionally omitted from this production list. +### Vendored dependency licenses + +`fancy-canvas@2.1.0` (a dependency of `lightweight-charts`) publishes a `files` +allowlist covering only its compiled JavaScript and type declarations, so its +MIT text is absent from the npm tarball. The upstream license from +https://github.com/tradingview/fancy-canvas is kept verbatim at +`frontend/vendor-licenses/fancy-canvas-LICENSE.txt`, and the Control UI builder +appends it to the generated ledger marked as vendored. Packages that ship no +license and have no vendored copy still fail the build. + ### React Control UI fonts - Inter Variable — Copyright (c) 2016 The Inter Project Authors diff --git a/docs/artifacts-and-media.md b/docs/artifacts-and-media.md index e68a4773..d76c308d 100644 --- a/docs/artifacts-and-media.md +++ b/docs/artifacts-and-media.md @@ -42,6 +42,67 @@ Use artifacts for: Use chat text for short answers, decisions, and next steps. +## Inline Charts + +Most artifacts render in Web UI chat as a download chip; images and audio render +inline. One more mime renders inline as an interactive chart: + +| Mime | Rendered as | +|------|-------------| +| `application/vnd.agentos.chart+json` | Candlestick chart with a volume histogram | + +There is nothing to register: any skill gets a chart by publishing a file with +that mime. Two steps, and both have a failure mode worth knowing. + +**1. Write the JSON inside the workspace.** Scripts run with the workspace as +their working directory, so a bare filename lands in the right place. +`publish_artifact` rejects anything outside the workspace, so an absolute path +like `/tmp/chart.json` fails *after* the data has already been fetched. + +**2. Publish it with the mime spelled out.** + +``` +publish_artifact path=bonk-1h.chart.json mime=application/vnd.agentos.chart+json +``` + +Passing `mime` is what makes the chart appear. Omit it and the filename guess +returns `application/json`, which classifies as a data artifact and renders as +an ordinary download chip — no error, just no chart. Tell the model to pass it +explicitly in your SKILL.md. + +The body is: + +```json +{ + "type": "candlestick", + "title": "BONK · 1h", + "subtitle": "SOL · 1h", + "candles": [ + { "time": 1754380800, "open": 1.2e-6, "high": 1.5e-6, "low": 1.1e-6, + "close": 1.4e-6, "volume": 12500.5 } + ] +} +``` + +`time` is a Unix timestamp in **seconds** and `volume` is optional; every other +candle field is required. Rows may arrive in any order and may repeat a +timestamp — the renderer sorts them and keeps the last entry per timestamp. +`title` and `subtitle` are display-only text, never markup. + +Hovering a candle reveals its open, high, low, close, volume, and the move from +open to close as a percentage, so there is no need to repeat those numbers in +the reply. + +The `gmgn-market` and `gmgn-token` skills each ship `scripts/kline_chart.py`, +which converts GMGN kline output into this shape; use it as a worked example +when adding charts to another skill. Note that they carry a copy each rather +than sharing one: `{baseDir}` resolves to the skill that is running, so a skill +cannot reach into another skill's `scripts/` directory — and the skill it +pointed at may not even be enabled. Give your skill its own copy. + +Charts render in the Web UI only. Other surfaces receive the artifact as a +normal JSON file, so keep a short text summary in the reply alongside the chart. + ## Document Skills AgentOS includes skills for common document formats: diff --git a/docs/features/skills.md b/docs/features/skills.md index c3cf35e7..0821e518 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -190,6 +190,25 @@ agentos skills tap remove Use taps when your team maintains its own skill catalog. +## What a Skill Can Put in the Chat + +Besides text, a skill hands files to the surface with `publish_artifact`. Most +arrive in Web UI chat as a download chip, but some mimes render inline instead: + +| Mime | Rendered as | +| --- | --- | +| `image/*` | Inline preview | +| `audio/*` | Inline player | +| `application/vnd.agentos.chart+json` | Interactive candlestick chart | + +Nothing has to be registered for this: the mime a skill publishes decides how +its output is drawn, so any skill can render a chart without a frontend change. + +[`artifacts-and-media.md`](../artifacts-and-media.md#inline-charts) carries the +payload shape, the workspace rule `publish_artifact` enforces, and the two +mistakes that produce no error and no chart — omitting the mime, and writing the +file outside the workspace. + ## Publish and Inspect Publish a skill directory: diff --git a/docs/tools-and-sandbox.md b/docs/tools-and-sandbox.md index b166c9de..175587fc 100644 --- a/docs/tools-and-sandbox.md +++ b/docs/tools-and-sandbox.md @@ -20,7 +20,7 @@ For a focused permissions guide, see | Web | `web_search`, `web_fetch`, `http_request`. | | Memory | `memory_search`, `memory_save`, `memory_get`, `memory_delete`, `memory`. | | Sessions | `sessions_send`, `sessions_spawn`, `sessions_list`, `sessions_history`, `session_status`. | -| Artifacts | `publish_artifact`. | +| Artifacts | `publish_artifact`. The mime decides the rendering: some draw inline in chat rather than as a download chip — see [artifacts-and-media.md](artifacts-and-media.md#inline-charts). | | Media | image generation, PDF, TTS, and media helpers. | | Skills | `skill_list`, `skill_view`, `skill_create`, `skill_edit`, `install_skill_deps`, `meta_invoke`. | | Control | cron scheduling and gateway control operations. | diff --git a/docs/web-ui.md b/docs/web-ui.md index 2897267d..3351bb08 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -196,7 +196,12 @@ cards for: - exported data files; - PDFs, slide decks, images, and other generated outputs. -For channel delivery limits and artifact recovery, see +Images and audio play inline in the card. An artifact published as +`application/vnd.agentos.chart+json` renders as an interactive candlestick +chart instead of a download chip — the `gmgn-market` and `gmgn-token` skills use +this for token price charts. + +For the chart payload shape, channel delivery limits, and artifact recovery, see [`artifacts-and-media.md`](artifacts-and-media.md). ## Approvals diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 361f3f44..40979010 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "clsx": "^2.1.1", "dompurify": "^3.4.12", "highlight.js": "^11.11.1", + "lightweight-charts": "^5.2.0", "lucide-react": "^1.25.0", "marked": "^18.0.7", "motion": "^12.42.2", @@ -3418,6 +3419,12 @@ "node": ">=12.0.0" } }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4179,6 +4186,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightweight-charts": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz", + "integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 15b3c788..20261c66 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "clsx": "^2.1.1", "dompurify": "^3.4.12", "highlight.js": "^11.11.1", + "lightweight-charts": "^5.2.0", "lucide-react": "^1.25.0", "marked": "^18.0.7", "motion": "^12.42.2", diff --git a/frontend/src/views/chat/chat-unified-css.test.ts b/frontend/src/views/chat/chat-unified-css.test.ts index 6de27d7a..a0ff24c9 100644 --- a/frontend/src/views/chat/chat-unified-css.test.ts +++ b/frontend/src/views/chat/chat-unified-css.test.ts @@ -46,6 +46,34 @@ describe('unified Chat CSS contract', () => { ) }) + it('reserves chart geometry and overlays the status so a drawn chart cannot shift the transcript', () => { + // The canvas is filled after a lazy import plus a payload fetch, so its + // height must exist before either resolves. + expect(css).toMatch(/\.chat-surface \.msg-artifact-chart__canvas \{[\s\S]*?height: 20rem;/) + // Canvas and status share one grid cell — hiding the status must not + // collapse a row. + expect(css).toMatch( + /\.chat-surface \.msg-artifact-chart__canvas,[\s\S]*?\.chat-surface \.msg-artifact-chart__status \{[\s\S]*?grid-row: 3;[\s\S]*?grid-column: 1;/, + ) + expect(css).toMatch(/\.chat-surface \.msg-artifact-charts \{[\s\S]*?max-width: 100%;/) + }) + + it('reserves the crosshair readout its own row so the first hover cannot shift the chart', () => { + // The strip is empty until a chart draws, so its height has to exist + // before the first crosshair move fills it. + expect(css).toMatch( + /\.chat-surface \.msg-artifact-chart__readout \{[\s\S]*?grid-row: 2;[\s\S]*?min-height: 1\.125rem;/, + ) + // Three rows: header, readout, then the canvas cell. + expect(css).toMatch( + /\.chat-surface \.msg-artifact-chart \{[\s\S]*?grid-template-rows: auto auto 1fr;/, + ) + // The strip must not swallow the crosshair it is reporting on. + expect(css).toMatch( + /\.chat-surface \.msg-artifact-chart__readout \{[\s\S]*?pointer-events: none;/, + ) + }) + it('reserves portalled header geometry before reactive controls mount', () => { expect(css).toMatch( /\.shell-chat-header__context \{[\s\S]*?min-height: 2\.5rem;[\s\S]*?overflow: visible;/, diff --git a/frontend/src/views/chat/chat-unified.css b/frontend/src/views/chat/chat-unified.css index 756bb759..6f0d238f 100644 --- a/frontend/src/views/chat/chat-unified.css +++ b/frontend/src/views/chat/chat-unified.css @@ -228,7 +228,8 @@ } .chat-surface .msg-artifact-gallery, -.chat-surface .msg-artifact-files { +.chat-surface .msg-artifact-files, +.chat-surface .msg-artifact-charts { display: grid; gap: 0.625rem; } @@ -241,6 +242,12 @@ max-width: min(36rem, 100%); } +/* Charts take the full transcript column — a candlestick series is unreadable + at gallery-card width. */ +.chat-surface .msg-artifact-charts { + max-width: 100%; +} + .chat-surface .msg-artifact-card { display: flex; flex-direction: column; @@ -285,6 +292,136 @@ font-weight: 600; } +/* Chart artifacts (chart.ts). The canvas is drawn asynchronously by + lightweight-charts after a lazy import plus a payload fetch, so the card + reserves its full height up front — same no-layout-shift contract the image + preview above follows. */ +.chat-surface .msg-artifact-chart { + display: grid; + grid-template-rows: auto auto 1fr; + gap: 0.5rem; + width: 100%; + padding: 0.75rem; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-surface); + background: color-mix(in srgb, var(--card) 92%, transparent); + color: var(--foreground); +} + +.chat-surface .msg-artifact-chart__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-width: 0; +} + +.chat-surface .msg-artifact-chart__name { + overflow: hidden; + font-size: 0.8125rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-surface .msg-artifact-chart__download { + flex-shrink: 0; + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.6875rem; + text-decoration: none; +} + +.chat-surface .msg-artifact-chart__download:hover { + color: var(--foreground); +} + +/* Canvas and status share one grid cell so the status can disappear on a + successful draw without collapsing a row and shifting the transcript. */ +.chat-surface .msg-artifact-chart__canvas, +.chat-surface .msg-artifact-chart__status { + grid-row: 3; + grid-column: 1; +} + +.chat-surface .msg-artifact-chart__canvas { + width: 100%; + height: 20rem; +} + +.chat-surface .msg-artifact-chart__status { + place-self: center; + margin: 0; + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.6875rem; + pointer-events: none; +} + +.chat-surface .msg-artifact-chart__status[hidden] { + display: none; +} + +/* Crosshair readout. lightweight-charts has no tooltip, so this strip carries + the hovered candle's OHLC and its move. It takes its own row above the + canvas rather than floating over it, so it can neither be clipped by the + card nor cover a candle, and it reserves its height up front so the first + hover does not shift the chart. */ +.chat-surface .msg-artifact-chart__readout { + display: flex; + flex-wrap: wrap; + gap: 0.0625rem 0.625rem; + grid-row: 2; + grid-column: 1; + min-height: 1.125rem; + padding: 0 0.125rem; + color: var(--muted-foreground); + font: 500 0.6875rem/1.125rem var(--font-mono); + pointer-events: none; +} + +.chat-surface .msg-artifact-chart__readout-cell[hidden] { + display: none; +} + +.chat-surface .msg-artifact-chart__readout-label { + margin-right: 0.1875rem; + opacity: 0.65; +} + +.chat-surface .msg-artifact-chart__readout-value { + color: var(--foreground); +} + +.chat-surface + .msg-artifact-chart__readout-cell[data-direction='up'] + .msg-artifact-chart__readout-value { + color: #26a69a; +} + +.chat-surface + .msg-artifact-chart__readout-cell[data-direction='down'] + .msg-artifact-chart__readout-value { + color: #ef5350; +} + +/* Mirror chartTheme()'s light palette so the move reads the same colour as the + candle body it describes. */ +:root[data-theme='light'] + .chat-surface + .msg-artifact-chart__readout-cell[data-direction='up'] + .msg-artifact-chart__readout-value { + color: #0f9d81; +} + +:root[data-theme='light'] + .chat-surface + .msg-artifact-chart__readout-cell[data-direction='down'] + .msg-artifact-chart__readout-value { + color: #e03131; +} + .chat-surface .msg-artifact-card__meta { color: var(--muted-foreground); font: 500 0.6875rem/1.4 var(--font-mono); diff --git a/frontend/src/views/chat/transcript/artifacts.test.ts b/frontend/src/views/chat/transcript/artifacts.test.ts index 9826019a..034482e3 100644 --- a/frontend/src/views/chat/transcript/artifacts.test.ts +++ b/frontend/src/views/chat/transcript/artifacts.test.ts @@ -5,20 +5,29 @@ // DOM (appendArtifact / renderArtifacts / renderStreamArtifacts / downloadArtifact) // is verified by a live-browser sweep (parity matrix), NOT here — it needs the // live streaming controller + a real gateway serving the download. +// +// The one exception is the chart placeholder, which has no legacy counterpart: +// it renders no visible card of its own, so a broken placeholder shows the user +// nothing at all rather than a wrong-looking chip. Its hooks and its handoff to +// the chart mounter are pinned below. -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { artifactMime, artifactName, artifactExtension, artifactCategory, artifactCategoryLabel, + createArtifactRenderer, isImageArtifact, isAudioArtifact, artifactDownloadUrl, artifactPreviewUrl, artifactAuthenticatedDownloadUrl, + type Artifact, + type ArtifactRendererDeps, } from './artifacts' +import { CHART_ARTIFACT_MIME } from './chart' /* ── artifactMime / artifactName (chat.js:7523-7529) ────────────────────── */ @@ -93,6 +102,14 @@ describe('artifactCategory (parity chat.js:7538)', () => { ) expect(artifactCategory({} as never)).toBe('file') }) + it('classifies the AgentOS chart mime as "chart" (no legacy counterpart)', () => { + // Wins over the +json extension fallback, which would otherwise land the + // chart payload in 'data' and render it as a download chip. + expect(artifactCategory({ mime: CHART_ARTIFACT_MIME, name: 'bonk.chart.json' } as never)).toBe( + 'chart', + ) + expect(artifactCategory({ mime: 'application/json', name: 'x.json' } as never)).toBe('data') + }) }) /* ── artifactCategoryLabel (chat.js:7551) ───────────────────────────────── */ @@ -103,6 +120,7 @@ describe('artifactCategoryLabel (parity chat.js:7551)', () => { expect(artifactCategoryLabel('document')).toBe('doc') expect(artifactCategoryLabel('code')).toBe('code') expect(artifactCategoryLabel('audio')).toBe('audio') + expect(artifactCategoryLabel('chart')).toBe('chart') }) it('defaults unknown / visual / file categories to "file"', () => { expect(artifactCategoryLabel('visual')).toBe('file') @@ -193,3 +211,128 @@ describe('artifactAuthenticatedDownloadUrl (parity chat.js:7583)', () => { ).toBe('/api/v1/artifacts/5?sessionKey=k') }) }) + +/* ── chart placeholder + mounter handoff (AgentOS-native) ───────────────── */ + +const CHART_ARTIFACT: Artifact = { + id: 'art-1', + name: 'bonk.chart.json', + mime: CHART_ARTIFACT_MIME, + download_url: '/api/v1/artifacts/art-1', +} + +function chartRendererDeps(overrides: Partial = {}) { + const bubble = document.createElement('div') + const body = document.createElement('div') + body.className = 'msg-body' + bubble.appendChild(body) + const streamArtifacts: Artifact[] = [] + const deps: ArtifactRendererDeps = { + ensureStreamBubble: () => bubble, + markVisibleStreamEvent: () => {}, + scrollToBottom: () => {}, + getAutoScroll: () => false, + getStreamBubble: () => bubble, + pushStreamArtifact: (artifact) => streamArtifacts.push(artifact), + getStreamArtifacts: () => streamArtifacts, + getSessionKey: () => 'agent:main:webchat:test', + getAuthToken: () => 'tok', + esc: (value) => value, + ...overrides, + } + return { deps, body, streamArtifacts } +} + +describe('createArtifactRenderer chart artifacts', () => { + it('renders a mount placeholder carrying the hooks the mounter looks for', () => { + const { deps } = chartRendererDeps() + + const container = document.createElement('div') + container.innerHTML = createArtifactRenderer(deps).renderArtifacts([CHART_ARTIFACT]) + + const host = container.querySelector('[data-chart-src]') + expect(host).not.toBeNull() + // The payload URL must be authenticated the same way a download is. + expect(host?.dataset.chartSrc).toBe( + '/api/v1/artifacts/art-1?sessionKey=agent%3Amain%3Awebchat%3Atest&token=tok', + ) + expect(host?.querySelector('.msg-artifact-chart__canvas')).not.toBeNull() + expect(host?.querySelector('.msg-artifact-chart__status')).not.toBeNull() + // The mounter fills this on draw; it must exist for the crosshair readout. + expect(host?.querySelector('.msg-artifact-chart__readout')).not.toBeNull() + expect(host?.querySelector('.msg-artifact-chart__name')).toHaveTextContent('bonk.chart.json') + // A chart groups with charts, never into the file-chip row. + expect(container.querySelector('.msg-artifact-charts')).not.toBeNull() + expect(container.querySelector('.msg-artifact-files')).toBeNull() + }) + + it('does not turn a click on the chart itself into a download', () => { + // useTranscript delegates clicks: any non-anchor element that resolves to + // [data-artifact-download] downloads the file. A chart is interactive, so + // the host must not carry it — otherwise every pan, zoom and crosshair + // click fetches the JSON instead of moving the chart. + const { deps } = chartRendererDeps() + + const container = document.createElement('div') + container.innerHTML = createArtifactRenderer(deps).renderArtifacts([CHART_ARTIFACT]) + + const canvas = container.querySelector('.msg-artifact-chart__canvas') + expect(canvas?.closest('[data-artifact-download]')).toBeNull() + expect(container.querySelector('.msg-artifact-chart')).not.toHaveAttribute( + 'data-artifact-download', + ) + }) + + it('keeps the download on the anchor, which the click handler steps aside for', () => { + const { deps } = chartRendererDeps() + + const container = document.createElement('div') + container.innerHTML = createArtifactRenderer(deps).renderArtifacts([CHART_ARTIFACT]) + + const link = container.querySelector('.msg-artifact-chart__download') + // Resolving to itself and being an anchor is exactly what makes the + // delegated handler leave it to the browser's native download. + expect(link?.closest('[data-artifact-download]')).toBe(link) + expect(link?.tagName).toBe('A') + }) + + it('still offers the raw payload as a download', () => { + const { deps } = chartRendererDeps() + + const container = document.createElement('div') + container.innerHTML = createArtifactRenderer(deps).renderArtifacts([CHART_ARTIFACT]) + + expect(container.querySelector('.msg-artifact-chart__download')).toHaveAttribute( + 'download', + 'bonk.chart.json', + ) + }) + + it('hands a streamed chart artifact to the mounter as soon as it lands', () => { + const mountCharts = vi.fn() + const { deps, body } = chartRendererDeps({ mountCharts }) + + createArtifactRenderer(deps).appendArtifact(CHART_ARTIFACT) + + // Without this call the placeholder sits at "Loading chart…" forever. + expect(mountCharts).toHaveBeenCalledWith(body) + expect(body.querySelector('[data-chart-src]')).not.toBeNull() + }) + + it('hands flushed stream artifacts to the mounter on the settle pass', () => { + const mountCharts = vi.fn() + const { deps, body, streamArtifacts } = chartRendererDeps({ mountCharts }) + streamArtifacts.push(CHART_ARTIFACT) + + createArtifactRenderer(deps).renderStreamArtifacts() + + expect(mountCharts).toHaveBeenCalledWith(body) + }) + + it('renders inert cards when no mounter is composed in', () => { + const { deps, body } = chartRendererDeps() + + expect(() => createArtifactRenderer(deps).appendArtifact(CHART_ARTIFACT)).not.toThrow() + expect(body.querySelector('[data-chart-src]')).not.toBeNull() + }) +}) diff --git a/frontend/src/views/chat/transcript/artifacts.ts b/frontend/src/views/chat/transcript/artifacts.ts index 80558218..ab8eaada 100644 --- a/frontend/src/views/chat/transcript/artifacts.ts +++ b/frontend/src/views/chat/transcript/artifacts.ts @@ -27,6 +27,8 @@ export { publishArtifactTargetName } from './tools' +import { isChartArtifact } from './chart' + /* ── Artifact shape ─────────────────────────────────────────────────────── */ /** The artifact payload the gateway emits (open-ended; only cited fields used). */ @@ -97,9 +99,12 @@ export function artifactExtension(name: string): string { return trimmed.slice(idx + 1) } -// chat.js:7538-7549 — category: visual | audio | data | document | code | file. +// chat.js:7538-7549 — category: visual | audio | data | document | code | file, +// plus the AgentOS-native 'chart' category (chart.ts) which has no legacy +// counterpart: it renders an inline chart rather than a download chip. // NOTE: image/* maps to 'visual' (NOT 'image' — the brief example was wrong). export function artifactCategory(artifact: Artifact | null | undefined): string { + if (isChartArtifact(artifact)) return 'chart' const mime = artifactMime(artifact) if (mime.startsWith('image/')) return 'visual' if (mime.startsWith('audio/')) return 'audio' @@ -115,6 +120,8 @@ export function artifactCategory(artifact: Artifact | null | undefined): string // chat.js:7551-7559 — category → chip glyph label. export function artifactCategoryLabel(category: string): string { switch (category) { + case 'chart': + return 'chart' case 'data': return 'data' case 'document': @@ -228,6 +235,12 @@ export interface ArtifactRendererDeps { toast?: (message: string, kind?: string, durationMs?: number) => void /** chat.js `_chatDiag` — the diagnostics ring. Default: no-op. */ diag?: (event: string, detail: Record) => void + /** + * Draw any chart placeholders that just entered `container` (chart.ts + * `mountCharts`). AgentOS-native — no legacy counterpart. Default: no-op, so + * a controller that never composes a mounter simply renders inert cards. + */ + mountCharts?: (container: HTMLElement) => void } /* ── Factory ────────────────────────────────────────────────────────────── */ @@ -245,6 +258,7 @@ export function createArtifactRenderer(deps: ArtifactRendererDeps) { const escAttr = deps.escAttr ?? deps.esc const toast = deps.toast ?? (() => {}) const diag = deps.diag ?? (() => {}) + const mountCharts = deps.mountCharts ?? ((): void => {}) const urlContext = (): ArtifactUrlContext => ({ sessionKey: deps.getSessionKey() || '', @@ -265,13 +279,15 @@ export function createArtifactRenderer(deps: ArtifactRendererDeps) { } artifacts.forEach((artifact) => { const category = artifactCategory(artifact) - const groupKind = category === 'visual' ? 'visual' : 'file' + const groupKind = category === 'visual' ? 'visual' : category === 'chart' ? 'chart' : 'file' if (groupKind !== openGroup) { closeGroup() html += groupKind === 'visual' ? '