Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release-windows-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/artifact-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jindw@xidea.org> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
);
});
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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('<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>');
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",
});
});
}
4 changes: 2 additions & 2 deletions apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -190,10 +190,7 @@ function ImageArtifactPreview(props: {
// result.state === 'image' — exhaustiveness check below.
return (
<div className="maka-artifact-preview-image">
<img
alt={props.record.name}
src={`data:${result.safeMime};base64,${result.base64}`}
/>
<MediaImageDisplay alt={props.record.name} src={result.src} onError={result.onError} />
</div>
);
}
Expand All @@ -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`.
Expand All @@ -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<PreviewResolution, { kind: 'unsupported' }>['reason'];
Expand All @@ -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 });
}
Expand Down
43 changes: 43 additions & 0 deletions docs/architecture/svg-preview.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!--
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.
-->

# SVG 静态预览

本变更补齐已有图片归档链路的 SVG 格式,以及 Markdown 中 SVG 代码块的显示,不修改 ACP 执行器或工具结果协议。原始文件保存为会话 Artifact,预览和下载使用处理后的静态 SVG。

## 显示策略

本地文件和下载内容共用 SVG 格式校验,原始文件保存为会话 Artifact。显示时从原始内容生成静态 SVG 预览,并通过图片元素加载,不注入应用 DOM。脚本、事件处理器、外部资源、动画、foreignObject、use 等不进入预览;允许基本几何、文本、渐变、受限行内样式和静态 class 样式规则。

完整的 `svg` 代码块,以及文档根元素为 `<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 插件替代,旧扫描器的性能数据不再代表当前实现。
7 changes: 5 additions & 2 deletions docs/astryx-surface-file-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -358,13 +358,16 @@ 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 |
| `packages/ui/src/skill-detail.tsx` | ui-composition | Banner, Button, HStack, MetadataList, MetadataListItem, StackItem, Switch, Text, VStack | aligned — uses Astryx (Banner, Button, HStack, MetadataList, MetadataListItem, StackItem, Switch, Text) | aligned |
| `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 |
Expand Down
3 changes: 3 additions & 0 deletions docs/astryx-surface-file-inventory.paths
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,16 @@ 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
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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading