diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index f6d426055c3..f842fcb4cc1 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -95,6 +95,7 @@ on: - 'packages/core/src/permission-profile-compiler.ts' - 'packages/core/src/sandbox-boundary.ts' - 'packages/core/src/serialized-byte-length.ts' + - 'packages/core/src/svg-preview.ts' - 'packages/core/src/windows-path.ts' - 'experiments/windows-sandbox/launcher/**' - 'experiments/windows-sandbox/*.ps1' diff --git a/apps/desktop/artifact-budget.json b/apps/desktop/artifact-budget.json index e3fb8839ea1..afa30390a0c 100644 --- a/apps/desktop/artifact-budget.json +++ b/apps/desktop/artifact-budget.json @@ -64,6 +64,7 @@ "node_modules/@vercel/oidc@3.2.0", "node_modules/@wecom/aibot-node-sdk@1.0.7", "node_modules/@workflow/serde@4.1.0", + "node_modules/@xmldom/xmldom@0.8.15", "node_modules/@xterm/addon-unicode11@0.9.0", "node_modules/@xterm/headless@6.0.0", "node_modules/agent-base@6.0.2", diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 276396fab5b..3af043a1ab3 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -5948,6 +5948,23 @@ See the LICENSE file in the root of this repository. ================================================================================ +Package: @xmldom/xmldom@0.8.15 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/xmldom/xmldom.git + +--- LICENSE --- +Copyright 2019 - present Christopher J. Brody and other contributors, as listed in: https://github.com/xmldom/xmldom/graphs/contributors +Copyright 2012 - 2017 @jindw and other contributors, as listed in: https://github.com/jindw/xmldom/graphs/contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: @xterm/addon-fit@0.11.0 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts b/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts index 4ae9aa4b6ed..87f5e60ffd0 100644 --- a/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts +++ b/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts @@ -31,7 +31,7 @@ import { describe('artifact preview registry', () => { it('treats present MIME metadata as authoritative and rejects unsafe formats', () => { assert.deepEqual( - resolvePreviewKind({ name: 'tricky.png', kind: 'image', mimeType: 'image/svg+xml' }), + resolvePreviewKind({ name: 'tricky.png', kind: 'image', mimeType: 'text/html' }), { kind: 'unsupported', reason: 'mime_disallowed' }, ); }); @@ -77,11 +77,11 @@ describe('artifact preview registry', () => { { ok: true, base64: 'A'.repeat(3 * 1024 * 1024), - mimeType: 'image/svg+xml', + mimeType: 'text/html', }, 'oversize', ], - [{ ok: true, base64: 'AAAA', mimeType: 'image/svg+xml' }, 'mime_disallowed'], + [{ ok: true, base64: 'AAAA', mimeType: 'text/html' }, 'mime_disallowed'], ]; for (const [result, reason] of rejected) { const outcome = decideImageReadOutcome(result); diff --git a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts index 5d7619bb042..63a9be295b5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts @@ -494,7 +494,7 @@ test("HTML Artifact does not invoke an opener when materialization fails", async test("Attachment byte IPC rejects preview-ineligible metadata before streaming", async () => { for (const [overrides, reason] of [ [{ id: "artifact-large", sizeBytes: 2 * 1024 * 1024 + 1 }, "too_large"], - [{ id: "artifact-svg", name: "vector.svg", mimeType: "image/svg+xml" }, "unsupported_mime"], + [{ id: "artifact-html", name: "page.html", mimeType: "text/html" }, "unsupported_mime"], ] as const) { let streamCalls = 0; const read = attachmentReadHandler(previewArtifact(overrides), async () => { @@ -520,3 +520,22 @@ test("Attachment byte IPC stops a stream that exceeds its preview admission", as { ok: false, reason: "too_large" }, ); }); + +for (const mimeType of ["image/svg+xml", undefined]) { + test(`Attachment byte IPC serves archived SVG with MIME ${mimeType ?? "extension fallback"}`, async () => { + const content = Buffer.from(''); + const read = attachmentReadHandler( + previewArtifact({ name: "vector.svg", mimeType, sizeBytes: content.length }), + async (_sessionId, _artifactId, writeChunk) => { + await writeChunk(content.subarray(0, 20)); + await writeChunk(content.subarray(20)); + return content.length; + }, + ); + assert.deepEqual(await read({}, "session-1", "artifact-1"), { + ok: true, + base64: content.toString("base64"), + mimeType: "image/svg+xml", + }); + }); +} diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 1a34e2ea100..7bc795d9bbe 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -25,7 +25,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { ARTIFACT_IMAGE_PREVIEW_MAX_BYTES, - normalizeArtifactImagePreviewMime, + normalizeMediaPreviewMime, resolveArtifactImagePreview, type ArtifactSaveResult, } from '@maka/core/artifacts'; @@ -208,7 +208,7 @@ export function registerRuntimeHostAttachmentPreviewIpc( reason: preview.reason === "oversize" ? "too_large" : "unsupported_mime", }; } - const mimeType = normalizeArtifactImagePreviewMime(artifact.mimeType, artifact.name); + const mimeType = normalizeMediaPreviewMime(artifact.mimeType, artifact.name); if (!mimeType) return { ok: false as const, reason: "unsupported_mime" }; const chunks: Buffer[] = []; let received = 0; diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx index bad400ef134..c5fa56d7143 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx @@ -17,11 +17,11 @@ * under the License. */ -/** Renderer shell for capped raster previews and typed unsupported outcomes. */ +/** Renderer shell for bounded image previews and typed unsupported outcomes. */ import { useEffect, useState } from 'react'; import type { ArtifactDescriptor } from '@maka/core/artifacts'; -import { Button, useUiLocale } from '@maka/ui'; +import { Button, useUiLocale, mediaImageSource, MediaImageDisplay } from '@maka/ui'; import { Banner } from '@astryxdesign/core/Banner'; import { Spinner } from '@astryxdesign/core/Spinner'; import { @@ -152,7 +152,7 @@ export function UnsupportedArtifactPreview(props: { * - The post-load L2 decision (`decideImageReadOutcome`) runs * INSIDE the async, BEFORE `setState`. The hook's state union * therefore holds either a `loading` placeholder, an `image` - * branch carrying the verified base64, OR an `unsupported` + * branch carrying the verified image source, OR an `unsupported` * branch that carries NO base64. A 10MB oversize payload never * enters React state / DevTools snapshot. * - The component below is intentionally a thin switch over the @@ -190,10 +190,7 @@ function ImageArtifactPreview(props: { // result.state === 'image' — exhaustiveness check below. return (
- {props.record.name} +
); } @@ -216,9 +213,8 @@ function toPreviewInput(record: ArtifactDescriptor): ArtifactPreviewInput { * branch). That meant a 10MB oversize payload could land in React * state / DevTools before the L2 cap rejected it for rendering. The * fix replaces the broad union with this closed three-way one and - * runs the L2 decision INSIDE the async, so the only `base64` that - * ever reaches `setState` is one that has already passed - * `decideImageReadOutcome` (cap + MIME re-validation). + * runs the L2 decision INSIDE the async. The source passed to state + * has passed MIME/size validation and, for SVG, static preview conversion. * * Source/test gate (locked by `artifact-preview-registry.test.ts`): * the `unsupported` branch type signature MUST NOT include `base64`. @@ -228,7 +224,7 @@ function toPreviewInput(record: ArtifactDescriptor): ArtifactPreviewInput { */ type ImagePreviewLoadState = | { state: 'loading' } - | { state: 'image'; safeMime: string; base64: string } + | { state: 'image'; src: string; onError(): void } | { state: 'unsupported'; reason: Extract['reason']; @@ -254,7 +250,10 @@ function useImagePreviewLoad( // receives the post-decision shape. const outcome = decideImageReadOutcome(raw); if (outcome.kind === 'image') { - setState({ state: 'image', safeMime: outcome.safeMime, base64: outcome.base64 }); + const src = await mediaImageSource(raw); + if (cancelled) return; + if (!src) { setState({ state: 'unsupported', reason: 'read_failed' }); return; } + setState({ state: 'image', src, onError: () => setState({ state: 'unsupported', reason: 'read_failed' }) }); } else { setState({ state: 'unsupported', reason: outcome.reason }); } diff --git a/docs/architecture/svg-preview.zh-CN.md b/docs/architecture/svg-preview.zh-CN.md new file mode 100644 index 00000000000..69808b1c8f5 --- /dev/null +++ b/docs/architecture/svg-preview.zh-CN.md @@ -0,0 +1,43 @@ + + +# SVG 静态预览 + +本变更补齐已有图片归档链路的 SVG 格式,以及 Markdown 中 SVG 代码块的显示,不修改 ACP 执行器或工具结果协议。原始文件保存为会话 Artifact,预览和下载使用处理后的静态 SVG。 + +## 显示策略 + +本地文件和下载内容共用 SVG 格式校验,原始文件保存为会话 Artifact。显示时从原始内容生成静态 SVG 预览,并通过图片元素加载,不注入应用 DOM。脚本、事件处理器、外部资源、动画、foreignObject、use 等不进入预览;允许基本几何、文本、渐变、受限行内样式和静态 class 样式规则。 + +完整的 `svg` 代码块,以及文档根元素为 `` 的 `xml` 代码块,在流式输出结束后提供预览、源码切换及静态预览下载。`xml` 的根元素候选检测不会加载解析器;真正显示前仍由共享 SVG 校验器验证完整文档。普通 XML 继续显示源码。代码块的原始源码由现有消息历史保存,下载按钮下载经过处理的 SVG。原文件仍保存在会话 Artifact 中,不改变现有产物列表的可见性规则。 + +显示支持与模型输入支持分开:SVG 不自动成为模型的视觉输入。现有 raster MIME 校验继续用于模型输入。 + +## 性能约束 + +- 普通文本不导入 SVG XML 解析器;前端解析模块使用动态 import。 +- 图片和 SVG 代码块在接近视口时才加载;流式 SVG 代码块只显示源码。 +- 单条正文自动预览最多 3 个 SVG,每个自动预览源码最多 64 Ki 字符,总计最多 128 Ki 字符,超出后显示显式预览按钮。预算在 Markdown 解析后的代码节点上按文档顺序计算,覆盖引用块和列表,`svg` 与 SVG 内容的 `xml` 共用预算;不改写消息源码。 +- SVG 源码超过 20,000 字符时保留完整文本和复制,但不生成语法高亮节点;该限制同时覆盖惰性模块加载占位、手动源码与复杂度拒绝后的降级显示,避免虚拟滚动重复挂载大量 token 节点。 +- SVG 最大 256 Ki 字符;在净化修改 DOM 之前,检查整棵输入树最多 4,000 个元素、8,000 个总节点、64 层元素深度,包括之后会被删除的元素、子树、注释和处理指令,避免逐个删除大量兄弟节点造成二次复杂度。总节点预算保留正常缩进空白的空间。静态 CSS 的注释和规则提取使用线性扫描,未闭合注释之后的内容不进入预览。 +- 会话图片读取合并并发请求;已读取图片缓存最多 32 项、8 Mi 字符,显式重试会重新读取。 +- SVG 转换缓存最多 16 项、1 Mi 字符,重复消费复用转换结果。 +## 局部性能基准 + +`node packages/ui/scripts/benchmark-session-media.mjs` 对比 Markdown 解析不带/带图表预算插件的成本。每种输入运行 7 轮、每轮 3,000 次,取中位数,覆盖普通正文、图片、代码、Mermaid、引用块 SVG 和 XML SVG。此基准不加载 XML 解析器、不生成图片,不代表真实长会话滚动帧率或应用整体启动性能。此前的源码预算扫描器已由 AST 插件替代,旧扫描器的性能数据不再代表当前实现。 diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index d61ef35c88e..3cc0592993d 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 347 files — blocker 0, reimplementation 0, polish 5, aligned 342. +**Totals:** 350 files — blocker 0, reimplementation 0, polish 5, aligned 345. ## Exclusions (explicit) @@ -327,7 +327,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/locale-context.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/maka-wordmark.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/markdown-body.tsx` | ui-composition | CodeBlock, Link, Markdown | aligned — uses Astryx (CodeBlock, Link, Markdown) | aligned | -| `packages/ui/src/markdown-image.tsx` | ui-composition | Button, IconButton, Link, Spinner, Tooltip | aligned — uses Astryx (Button, IconButton, Link, Spinner, Tooltip) | aligned | +| `packages/ui/src/markdown-image.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/markdown-math.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/markdown.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/mermaid-diagram.tsx` | ui-composition | Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar | aligned — uses Astryx (Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar) | aligned | @@ -358,6 +358,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/session-context-layer.tsx` | shell-chrome-or-panel | Button, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList, StatusDot, Text, Token, Tooltip | aligned — uses Astryx (Button, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList, StatusDot) | aligned | | `packages/ui/src/session-history-list.tsx` | shell-chrome-or-panel | Badge, Button, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack | aligned — uses Astryx (Badge, Button, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack) | aligned | | `packages/ui/src/session-list-panel.tsx` | shell-chrome-or-panel | SegmentedControl, SegmentedControlItem, SideNav, Text | aligned — uses Astryx (SegmentedControl, SegmentedControlItem, SideNav, Text) | aligned | +| `packages/ui/src/session-media-image.tsx` | shell-chrome-or-panel | Button, IconButton, Link, Spinner, Tooltip | aligned — uses Astryx (Button, IconButton, Link, Spinner, Tooltip) | aligned | | `packages/ui/src/session-rail-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/session-rename-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned | | `packages/ui/src/session-sidebar-nav.tsx` | shell-chrome-or-panel | Icon, IconButton, SideNavItem, SideNavSection, Tooltip | aligned — uses Astryx (Icon, IconButton, SideNavItem, SideNavSection, Tooltip) | aligned | @@ -365,6 +366,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/skills-panel.tsx` | module-hub | Button, DropdownMenu, DropdownMenuItem, DropdownMenuSubMenu, EmptyState, IconButton, List, Text, TextInput | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, DropdownMenuSubMenu, EmptyState, IconButton, List, Text) | aligned | | `packages/ui/src/status-label.tsx` | ui-composition | HStack, StatusDot, Text | aligned — uses Astryx (HStack, StatusDot, Text) | aligned | | `packages/ui/src/styles.css` | ui-composition | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | +| `packages/ui/src/svg-code-preview.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned | +| `packages/ui/src/svg-code-source.tsx` | ui-composition | CodeBlock | aligned — uses Astryx (CodeBlock) | aligned | | `packages/ui/src/titlebar-session-identity.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuItem, IconButton | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, IconButton) | aligned | | `packages/ui/src/toast.tsx` | ui-composition | AlertDialog, Button, HStack, LayerProvider, Text, VStack | aligned — uses Astryx (AlertDialog, Button, HStack, LayerProvider, Text, VStack) | aligned | | `packages/ui/src/tool-activity.tsx` | ui-composition | Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden | aligned — uses Astryx (Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index fcb5fc4b79f..96a18f9d12b 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -327,6 +327,7 @@ packages/ui/src/search-modal.tsx packages/ui/src/session-context-layer.tsx packages/ui/src/session-history-list.tsx packages/ui/src/session-list-panel.tsx +packages/ui/src/session-media-image.tsx packages/ui/src/session-rail-context.tsx packages/ui/src/session-rename-dialog.tsx packages/ui/src/session-sidebar-nav.tsx @@ -334,6 +335,8 @@ packages/ui/src/skill-detail.tsx packages/ui/src/skills-panel.tsx packages/ui/src/status-label.tsx packages/ui/src/styles.css +packages/ui/src/svg-code-preview.tsx +packages/ui/src/svg-code-source.tsx packages/ui/src/titlebar-session-identity.tsx packages/ui/src/toast.tsx packages/ui/src/tool-activity.tsx diff --git a/package-lock.json b/package-lock.json index 34867081cb9..17309f5c1e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6344,7 +6344,6 @@ "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17439,6 +17438,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@xmldom/xmldom": "^0.8.15", "intl-messageformat": "11.2.15", "marked": "18.0.11" } diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index 68eca268d26..6feca3aa7d2 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -2065,6 +2065,23 @@ See the LICENSE file in the root of this repository. ================================================================================ +Package: @xmldom/xmldom@0.8.15 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/xmldom/xmldom.git + +--- LICENSE --- +Copyright 2019 - present Christopher J. Brody and other contributors, as listed in: https://github.com/xmldom/xmldom/graphs/contributors +Copyright 2012 - 2017 @jindw and other contributors, as listed in: https://github.com/jindw/xmldom/graphs/contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: @xterm/addon-unicode11@0.9.0 Declared license: MIT Selected license: MIT diff --git a/packages/core/package.json b/packages/core/package.json index 83b3ba5a835..aed37d6ea24 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -173,7 +173,8 @@ "./maka-wordmark": "./dist/maka-wordmark.js", "./test-only/async-primitives": "./dist/test-only/async-primitives.js", "./workhub-action-result": "./dist/workhub-action-result.js", - "./workhub-routing": "./dist/workhub-routing.js" + "./workhub-routing": "./dist/workhub-routing.js", + "./svg-preview": "./dist/svg-preview.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo src/model-metadata.generated.ts", @@ -184,6 +185,7 @@ "test:dist": "node --test \"dist/**/*.test.js\"" }, "dependencies": { + "@xmldom/xmldom": "^0.8.15", "intl-messageformat": "11.2.15", "marked": "18.0.11" } diff --git a/packages/core/src/__tests__/svg-preview.test.ts b/packages/core/src/__tests__/svg-preview.test.ts new file mode 100644 index 00000000000..fda2bcf5833 --- /dev/null +++ b/packages/core/src/__tests__/svg-preview.test.ts @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { normalizeArtifactImagePreviewMime, normalizeMediaPreviewMime } from '../artifacts.js'; +import { prepareSvgPreview, SVG_PREVIEW_MAX_CHARS, SVG_PREVIEW_MAX_NODES } from '../svg-preview.js'; + +test('SVG display support does not expand model image admission', () => { + assert.equal(normalizeMediaPreviewMime('image/svg+xml'), 'image/svg+xml'); + assert.equal(normalizeMediaPreviewMime(undefined, 'drawing.svg'), 'image/svg+xml'); + assert.equal(normalizeArtifactImagePreviewMime('image/svg+xml'), null); +}); + +test('static SVG preserves geometry, text, gradients and safe inline styles', () => { + const source = + '你好 & Maka'; + const result = prepareSvgPreview(source); + assert.match(result, /linearGradient/); + assert.match(result, /url\(#paint\)/); + assert.match(result, /你好 & Maka/); + assert.match(result, /font-size:20px;fill:white/); + assert.match(prepareSvgPreview(''), /circle/); +}); + +test('SVG strips executable content, external dependencies and CSS escapes', () => { + const result = prepareSvgPreview( + '
html
', + ); + assert.doesNotMatch( + result, + /script|onload|foreignObject|https:| { + for (const source of [ + '', + ']>&x;', + '', + '', + '' + ''.repeat(66) + ''.repeat(66) + '', + '' + ''.repeat(4001) + '', + '' + ' '.repeat(SVG_PREVIEW_MAX_CHARS) + '', + ]) + assert.throws(() => prepareSvgPreview(source)); +}); + +test('static SVG class styles preserve generated diagram colors without external CSS', () => { + const result = prepareSvgPreview( + '', + ); + assert.match(result, /class="box"/); + assert.match(result, /\.box\{fill:#4355d9;stroke:white\}/); + assert.doesNotMatch(result, /https:|@import|background/); +}); + +test('static SVG styles preserve safe rules around comments and malformed rules', () => { + const result = prepareSvgPreview( + '', + ); + assert.match(result, /\.a\{fill:red\}/); + assert.match(result, /\.b\{stroke:blue\}/); + assert.match(result, /\.c\{fill:green\}/); + assert.doesNotMatch(result, /stray|outer|hidden|unterminated|leading/); +}); + +test('static SVG style parsing stays bounded for long malformed CSS', () => { + for (const css of ['x'.repeat(32000), '/*'.repeat(16000)]) { + const source = + '' + + ``.repeat(8) + + ''; + assert.ok(source.length < SVG_PREVIEW_MAX_CHARS); + const started = process.cpuUsage(); + const result = prepareSvgPreview(source); + const elapsed = process.cpuUsage(started); + // CPU time avoids wall-clock failures under load; the old scanner took seconds. + assert.ok( + elapsed.user + elapsed.system < 500_000, + 'Malformed CSS must not consume 500 ms of CPU', + ); + assert.doesNotMatch(result, /
'); + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return { container, root }; +} + +test('repeated images and virtualized remounts share a bounded saved-image cache', async () => { + const { SessionMediaImage } = await import('../session-media-image.js'); + const { container, root } = domRoot(); + let reads = 0; + const readBytes: ReadAttachmentBytes = async () => { + reads++; + return { ok: true, base64: 'AAAA', mimeType: 'image/png' }; + }; + const render = (visible: boolean) => root.render( + {visible && <>} + ); + await act(async () => render(true)); + assert.equal(container.querySelectorAll('img').length, 2); + assert.equal(reads, 1); + await act(async () => render(false)); + await act(async () => render(true)); + assert.equal(reads, 1, 'remount must not reread or re-encode saved bytes'); +}); + +test('saved SVG previews sanitize content before image state and cache identical conversions', async () => { + const { mediaImageSource } = await import('../media-image-source.js'); + const svg = ''; + const result = { ok: true as const, mimeType: 'image/svg+xml', base64: Buffer.from(svg).toString('base64') }; + const [first, second] = await Promise.all([mediaImageSource(result), mediaImageSource(result)]); + assert.equal(first, second); + assert.ok(first); + assert.doesNotMatch(decodeURIComponent(first), /script|onload/); + assert.match(decodeURIComponent(first), /rect/); + await assert.rejects(mediaImageSource({ ...result, base64: Buffer.from('not SVG').toString('base64') })); +}); diff --git a/packages/ui/src/artifact-preview-registry.ts b/packages/ui/src/artifact-preview-registry.ts index 89158dff5ca..37263f897d6 100644 --- a/packages/ui/src/artifact-preview-registry.ts +++ b/packages/ui/src/artifact-preview-registry.ts @@ -21,7 +21,7 @@ import { ARTIFACT_IMAGE_PREVIEW_MAX_BYTES, - normalizeArtifactImagePreviewMime, + normalizeMediaPreviewMime, resolveArtifactImagePreview, type ArtifactBinaryReadResult, type ArtifactImagePreviewInput, @@ -53,7 +53,7 @@ function decideImagePostLoad(input: { if (exceedsImagePayloadCap(input.base64)) { return { kind: 'unsupported', reason: 'oversize' }; } - const safeMime = normalizeArtifactImagePreviewMime(input.mimeType); + const safeMime = normalizeMediaPreviewMime(input.mimeType); if (!safeMime) { return { kind: 'unsupported', reason: 'mime_disallowed' }; } diff --git a/packages/ui/src/attachment-image.tsx b/packages/ui/src/attachment-image.tsx index dc52a3b3311..15884fe6e33 100644 --- a/packages/ui/src/attachment-image.tsx +++ b/packages/ui/src/attachment-image.tsx @@ -27,6 +27,7 @@ import { type ReactNode, } from 'react'; import type { ArtifactBinaryReadResult } from '@maka/core/artifacts'; +import { mediaImageSource } from './media-image-source.js'; import { decideImageReadOutcome } from './artifact-preview-registry.js'; /** Host capability for reading bytes from the Runtime Host attachment authority. */ @@ -35,9 +36,11 @@ export type ReadAttachmentBytes = ( artifactId: string, ) => Promise; +type LoadedImage = { src?: string; limited?: boolean }; + type SessionAttachmentContextValue = { sessionId: string; - loadImage: (sessionId: string, artifactId: string) => Promise; + loadImage: (sessionId: string, artifactId: string, retry?: boolean) => Promise; }; const SessionAttachmentContext = createContext(undefined); @@ -52,21 +55,36 @@ export function SessionAttachmentProvider(props: { () => { const readBytes = props.readBytes; if (!readBytes) return undefined; - const pending = new Map>(); + const pending = new Map>(); + const ready = new Map(); + let readyChars = 0; return { sessionId: props.sessionId, - loadImage(sessionId: string, artifactId: string) { + loadImage(sessionId: string, artifactId: string, _retry = false) { const key = `${sessionId}\0${artifactId}`; + if (_retry) { readyChars -= ready.get(key)?.src?.length ?? 0; ready.delete(key); } + const cached = ready.get(key); + if (cached) { ready.delete(key); ready.set(key, cached); return Promise.resolve(cached); } const existing = pending.get(key); if (existing) return existing; const loaded = readBytes(sessionId, artifactId) - .then((result) => { + .then(async (result): Promise => { const outcome = decideImageReadOutcome(result); return outcome.kind === 'image' - ? `data:${outcome.safeMime};base64,${outcome.base64}` - : undefined; + ? { src: await mediaImageSource(result) } + : { limited: outcome.reason === 'oversize' }; + }) + .then(image => { + if (image.src) { + ready.set(key, image); readyChars += image.src.length; + while (ready.size > 32 || readyChars > 8 * 1024 * 1024) { + const oldest = ready.keys().next().value!; + readyChars -= ready.get(oldest)?.src?.length ?? 0; ready.delete(oldest); + } + } + return image; }) - .catch(() => undefined); + .catch((): LoadedImage => ({})); pending.set(key, loaded); void loaded.finally(() => { if (pending.get(key) === loaded) pending.delete(key); @@ -120,9 +138,9 @@ export function useAttachmentImage(ref: { if (!request) return; setResult({ request, status: 'loading' }); let cancelled = false; - request.loadImage(request.sessionId, request.artifactId) + request.loadImage(request.sessionId, request.artifactId, request.attempt > 0) .then((loaded) => { - if (!cancelled) setResult({ request, status: loaded ? 'ready' : 'failed', src: loaded }); + if (!cancelled) setResult({ request, status: loaded.src ? 'ready' : 'failed', src: loaded.src }); }); return () => { cancelled = true; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4a04caf2a19..f995d0a1b01 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -206,3 +206,7 @@ export { formatCompactTokenCount } from './compact-token-count.js'; export * from './revision-staged-context.js'; export { TranscriptDisclosure } from './transcript-disclosure.js'; + +export { SessionMediaImage } from './session-media-image.js'; +export { mediaImageSource } from './media-image-source.js'; +export { MediaImageDisplay } from './session-media-image.js'; diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index d11b15fe1be..ee58cc83c38 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -30,11 +30,14 @@ */ import { MarkdownImage, createMarkdownImagePlugins } from './markdown-image.js'; -import { useCallback, useContext, useMemo, useRef, type ReactNode } from 'react'; +import { lazy, Suspense, useCallback, useContext, useMemo, useRef, type ReactNode } from 'react'; import { Markdown as AstryxMarkdown, type MarkdownComponents, + type MarkdownAstNode, + type MarkdownAstRoot, } from '@astryxdesign/core/Markdown'; +import { createMarkdownPlugin, type MarkdownExtensionNode } from '@astryxdesign/core/Markdown/plugins'; import { Link as AstryxLink } from '@astryxdesign/core/Link'; import { CodeBlock } from '@astryxdesign/core/CodeBlock'; import { useTranslator } from '@astryxdesign/core/i18n'; @@ -54,6 +57,10 @@ import { prepareMarkdownMath, } from './markdown-math.js'; +import { SvgCodeSource } from './svg-code-source.js'; + +const SvgCodePreview = lazy(() => import('./svg-code-preview.js')); + const BASE_MARKDOWN_COMPONENTS = { link: MarkdownLink, image: MarkdownImage, @@ -65,53 +72,75 @@ export const MAX_AUTOMATIC_MERMAID_SOURCE_LENGTH = 4_000; export const MAX_AUTOMATIC_MERMAID_TOTAL_SOURCE_LENGTH = 8_000; const CODE_BLOCK_COLLAPSIBLE_THRESHOLD = 10; const DEFERRED_MERMAID_LANGUAGE = 'makamermaiddeferred'; +const DEFERRED_SVG_LANGUAGE = 'makasvgdeferred'; +export const MAX_AUTOMATIC_SVG_DIAGRAMS = 3; +export const MAX_AUTOMATIC_SVG_SOURCE_LENGTH = 64 * 1024; -/** - * Mark settled Mermaid fences that exceed the per-document automatic-render - * budget. The private language marker survives Astryx parsing without changing - * the source shown to the user; the code renderer turns it into an explicit - * source + Render action instead of scheduling more main-thread layout work. - */ -export function applyMermaidRenderBudget(source: string): string { - const lines = source.split('\n'); - let automaticCount = 0; - let automaticSourceLength = 0; - - for (let index = 0; index < lines.length; index += 1) { - const opening = /^( {0,3})(`{3,}|~{3,})([^\n]*)$/.exec(lines[index] ?? ''); - if (!opening) continue; - const [, indent = '', fence = '', info = ''] = opening; - const language = /^([ \t]*)mermaid(?=[ \t]|$)/i.exec(info); - if (!language) continue; - - const fenceCharacter = fence[0]; - if (!fenceCharacter) continue; - const closing = new RegExp(`^ {0,3}${fenceCharacter}{${fence.length},}[ \\t]*$`); - let closingIndex = index + 1; - while (closingIndex < lines.length && !closing.test(lines[closingIndex] ?? '')) { - closingIndex += 1; - } - if (closingIndex >= lines.length) continue; - - const codeLength = lines.slice(index + 1, closingIndex).join('\n').length; - const withinBudget = - codeLength <= MAX_AUTOMATIC_MERMAID_SOURCE_LENGTH - && automaticCount < MAX_AUTOMATIC_MERMAID_DIAGRAMS - && automaticSourceLength + codeLength <= MAX_AUTOMATIC_MERMAID_TOTAL_SOURCE_LENGTH; - - if (withinBudget) { - automaticCount += 1; - automaticSourceLength += codeLength; - } else { - const leadingWhitespace = language[1] ?? ''; - lines[index] = `${indent}${fence}${leadingWhitespace}${DEFERRED_MERMAID_LANGUAGE}${info.slice(language[0].length)}`; - } - index = closingIndex; +/** Admit XML whose document element looks like SVG; the lazy sanitizer validates + * the complete document before any preview bytes reach an image element. */ +function isSvgXml(code: string): boolean { + let offset = 0; + const skipWhitespace = () => { + while (offset < code.length && /\s/.test(code[offset]!)) offset++; + }; + skipWhitespace(); + if (code.startsWith('', offset + 5); + if (end < 0) return false; + offset = end + 2; + skipWhitespace(); } - - return lines.join('\n'); + // Advance through each comment once; nested regex repetitions can backtrack + // exponentially on a long comment-only XML block without an SVG root. + while (code.startsWith('', offset + 4); + if (end < 0) return false; + offset = end + 3; + skipWhitespace(); + } + return code.startsWith(']/.test(code[offset + 4] ?? ''); } +/** Budget actual code nodes in document order, including blockquotes and lists. + * Source text stays unchanged, and streaming messages don't install this plugin. */ +export const markdownDiagramPlugin = createMarkdownPlugin({ + name: 'maka-diagram-previews', + apiVersion: 1, + transform(document, context) { + if (!/mermaid|svg/i.test(context.source)) return document; + let svgCount = 0; + let svgLength = 0; + let mermaidCount = 0; + let mermaidLength = 0; + const rewrite = (node: MarkdownAstNode): MarkdownAstNode => { + if (node.type === 'code') { + const language = node.lang?.trim().toLowerCase(); + const svg = language === 'svg' || (language === 'xml' && isSvgXml(node.value)); + if (!svg && language !== 'mermaid') return node; + const size = node.value.length; + const withinBudget = svg + ? size <= MAX_AUTOMATIC_SVG_SOURCE_LENGTH && svgCount < MAX_AUTOMATIC_SVG_DIAGRAMS + && svgLength + size <= 2 * MAX_AUTOMATIC_SVG_SOURCE_LENGTH + : size <= MAX_AUTOMATIC_MERMAID_SOURCE_LENGTH && mermaidCount < MAX_AUTOMATIC_MERMAID_DIAGRAMS + && mermaidLength + size <= MAX_AUTOMATIC_MERMAID_TOTAL_SOURCE_LENGTH; + if (withinBudget) { + if (svg) { svgCount++; svgLength += size; } + else { mermaidCount++; mermaidLength += size; } + } + const lang = svg + ? withinBudget ? 'svg' : DEFERRED_SVG_LANGUAGE + : withinBudget ? 'mermaid' : DEFERRED_MERMAID_LANGUAGE; + return lang === node.lang ? node : { ...node, lang }; + } + if (!('children' in node)) return node; + const children = node.children.map(rewrite); + return children.every((child, index) => child === node.children[index]) + ? node : { ...node, children } as MarkdownAstNode; + }; + return rewrite(document) as MarkdownAstRoot; + }, +}); + // Whether the prose (not code) of a markdown source is written in Han script. // The document `lang` is the UI locale, which says nothing about what the // model wrote, so the CSS that styles Han-specific runs (emphasis has no @@ -156,9 +185,9 @@ export function MarkdownBody(props: { ); const plugins = useMemo(() => [ markdownCodeLanguagePlugin, + ...(props.streaming ? [] : [markdownDiagramPlugin]), ...createMarkdownImagePlugins(props.imageDisplay), - ], [props.imageDisplay]); - const budgetedText = props.streaming ? props.text : applyMermaidRenderBudget(props.text); + ], [props.imageDisplay, props.streaming]); const density = props.density ?? 'default'; const components = props.streaming ? density === 'compact' @@ -205,39 +234,39 @@ export function MarkdownBody(props: { settledText={props.settledText} transformSource={transformMathSource} > - {budgetedText} + {props.text} ); } function MarkdownCodeDefault(props: { code: string; language?: string }) { - return ; + return ; } function MarkdownCodeCompact(props: { code: string; language?: string }) { - return ; + return ; } function MarkdownCodeStreamingDefault(props: { code: string; language?: string }) { - return ; + return ; } function MarkdownCodeStreamingCompact(props: { code: string; language?: string }) { - return ; + return ; } function MarkdownCode(props: { code: string; language?: string; density: 'default' | 'compact'; - renderMermaid: boolean; + renderDiagrams: boolean; streaming?: boolean; }) { const t = useTranslator(); const tokenizer = useMarkdownCodeTokenizer(props.code, props.language, props.streaming === true); const language = props.language?.trim().toLowerCase(); - if (props.renderMermaid && (language === 'mermaid' || language === DEFERRED_MERMAID_LANGUAGE)) { + if (props.renderDiagrams && (language === 'mermaid' || language === DEFERRED_MERMAID_LANGUAGE)) { return ( }>; + } + const codeLines = props.code.split('\n'); if (codeLines.length > 1 && codeLines.at(-1) === '') codeLines.pop(); const isSingleLine = codeLines.length === 1; diff --git a/packages/ui/src/markdown-image.tsx b/packages/ui/src/markdown-image.tsx index 5b66689ad34..59ef00081af 100644 --- a/packages/ui/src/markdown-image.tsx +++ b/packages/ui/src/markdown-image.tsx @@ -17,18 +17,13 @@ * under the License. */ -import { useEffect, useRef, useState } from 'react'; -import { Button, IconButton, Spinner, Tooltip } from '@astryxdesign/core'; import { createMarkdownPlugin, type MarkdownExtensionNode } from '@astryxdesign/core/Markdown/plugins'; import type { MarkdownAstNode, MarkdownAstRoot } from '@astryxdesign/core/Markdown'; -import { RotateCw, AlertTriangle } from './icons.js'; -import { Link } from '@astryxdesign/core/Link'; -import { useChatImageResource } from './chat-image-resource.js'; -import { getSharedUiCopy } from './shared-ui-copy.js'; -import { useUiLocale } from './locale-context.js'; import { createMarkdownImageSourceResolver } from './markdown-image-source.js'; import { redactSecrets } from './redact.js'; +import { SessionMediaImage as MarkdownImage } from './session-media-image.js'; + type PlacedImage = MarkdownExtensionNode<'maka-images', 'image', { src: string; alt: string; inline: boolean; redacted: boolean }>; // Markdown structure determines inline/block placement; decoded images supply // their natural dimensions within the presentation's CSS bounds. @@ -78,54 +73,5 @@ function isSingleImage(nodes: readonly MarkdownAstNode[]): boolean { return node.type === 'image' || (['link', 'strong', 'emphasis', 'delete'].includes(node.type) && 'children' in node && isSingleImage(node.children)); } -/** Presentation only: Host resolves local addresses and archives; UI receives artifact identities. */ -export function MarkdownImage(props: { src: string; alt: string; inline?: boolean; redacted?: boolean }) { - return ; -} -function ImageResource(props: { src: string; redacted: boolean; alt: string; inline?: boolean }) { - const copy = getSharedUiCopy(useUiLocale()).markdown; - const anchor = useRef(null); - const [visible, setVisible] = useState(typeof IntersectionObserver === 'undefined'); - useEffect(() => { - if (visible || !anchor.current) return; - const observer = new IntersectionObserver(entries => { - if (entries.some(entry => entry.isIntersecting)) { setVisible(true); observer.disconnect(); } - }, { rootMargin: '300px' }); - observer.observe(anchor.current); return () => observer.disconnect(); - }, [visible]); - const resource = useChatImageResource({ source: props.src, redacted: props.redacted, visible }); - const state = resource.presentation; - const message = state.kind === 'loading' ? copy.imageLoading - : state.kind === 'failed' ? state.reason === 'saved_bytes' ? copy.imageLoadFailed : copy.imageArchiveFailure(state.reason) - : state.kind === 'unavailable' ? { - attachment: copy.imageUnavailable, - remote: copy.imageRemoteUnavailable, - redacted: copy.imageRemoteRedacted, - unsupported: copy.imageUnsupported, - }[state.reason] : undefined; - const actions = - {resource.retry &&