From 9ca61c9707f613015043bd1cecbcf19b87eaf5ff Mon Sep 17 00:00:00 2001 From: Key KQ Date: Wed, 5 Aug 2026 22:10:25 +0700 Subject: [PATCH 1/7] feat(chat): render token price charts inline in Web UI chat Artifacts published as `application/vnd.agentos.chart+json` now draw as an interactive candlestick chart in the chat transcript instead of a download chip, and the gmgn skills emit one alongside their text summaries. The chart goes through the existing artifact seam, so history replay redraws it without a separate code path. lightweight-charts is imported dynamically: it lands in its own 63.5 KB gz chunk and never loads for a chat with no chart, leaving the Chat route at 93.8 KB gz against a 180 KB budget. Payload strings (token names and symbols) are attacker-controlled on-chain metadata, so every one of them reaches the DOM through textContent. - chart.ts: payload normalization (ms->s, sort, dedupe by timestamp) plus a lazy, theme-aware mounter - artifacts.ts: new `chart` category rendering a mount placeholder - gmgn-market: scripts/kline_chart.py converts kline output to the payload - gmgn-token: fetches a chart during research via the gmgn-market skill --- THIRD_PARTY_NOTICES.md | 2 + docs/artifacts-and-media.md | 36 ++ docs/web-ui.md | 7 +- frontend/package-lock.json | 16 + frontend/package.json | 1 + .../src/views/chat/chat-unified-css.test.ts | 12 + frontend/src/views/chat/chat-unified.css | 80 +++- .../views/chat/transcript/artifacts.test.ts | 10 + .../src/views/chat/transcript/artifacts.ts | 43 +- .../src/views/chat/transcript/chart.test.ts | 208 +++++++++ frontend/src/views/chat/transcript/chart.ts | 424 ++++++++++++++++++ frontend/src/views/chat/transcript/history.ts | 11 +- frontend/src/views/chat/transcript/stream.ts | 6 + frontend/src/views/chat/useTranscript.ts | 42 ++ .../skills/bundled/gmgn-market/SKILL.md | 44 ++ .../gmgn-market/scripts/kline_chart.py | 170 +++++++ .../skills/bundled/gmgn-token/SKILL.md | 19 + 17 files changed, 1123 insertions(+), 8 deletions(-) create mode 100644 frontend/src/views/chat/transcript/chart.test.ts create mode 100644 frontend/src/views/chat/transcript/chart.ts create mode 100644 src/agentos/skills/bundled/gmgn-market/scripts/kline_chart.py diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 78628f8c..2c4e58a4 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 diff --git a/docs/artifacts-and-media.md b/docs/artifacts-and-media.md index e68a4773..0906fdb0 100644 --- a/docs/artifacts-and-media.md +++ b/docs/artifacts-and-media.md @@ -42,6 +42,42 @@ 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 | + +Publish a JSON file with that mime and the chat draws it in place of the chip. +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. + +The `gmgn-market` skill ships `scripts/kline_chart.py`, which converts GMGN +kline output into this shape; use it as a worked example when adding charts to +another skill. + +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/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..b2d7668b 100644 --- a/frontend/src/views/chat/chat-unified-css.test.ts +++ b/frontend/src/views/chat/chat-unified-css.test.ts @@ -46,6 +46,18 @@ 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: 2;[\s\S]*?grid-column: 1;/, + ) + expect(css).toMatch(/\.chat-surface \.msg-artifact-charts \{[\s\S]*?max-width: 100%;/) + }) + 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..6f2507db 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,77 @@ 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 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: 2; + 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; +} + .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..5d54b908 100644 --- a/frontend/src/views/chat/transcript/artifacts.test.ts +++ b/frontend/src/views/chat/transcript/artifacts.test.ts @@ -19,6 +19,7 @@ import { artifactPreviewUrl, artifactAuthenticatedDownloadUrl, } from './artifacts' +import { CHART_ARTIFACT_MIME } from './chart' /* ── artifactMime / artifactName (chat.js:7523-7529) ────────────────────── */ @@ -93,6 +94,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 +112,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') diff --git a/frontend/src/views/chat/transcript/artifacts.ts b/frontend/src/views/chat/transcript/artifacts.ts index 80558218..2c9e5290 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' ? '