You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/29 11:09:33 UTC
(maka) branch feat/4093-markdown-attachment-images updated (eea2d68e3 -> c7c12344f)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a change to branch feat/4093-markdown-attachment-images
in repository https://gitbox.apache.org/repos/asf/maka.git
omit eea2d68e3 fix: enforce one attachment image preview policy
omit a2f108720 fix(ui): retry transient attachment image reads
omit f9c25f4db fix(runtime): preserve attachment refs in conversation copies
omit 24cd5c60b fix: harden session attachment image loading
omit 66ef78214 chore(ui): align attachment image surface metadata
omit 8914432e3 feat(ui): render session attachment images in Markdown
add bd2ec4748 refactor(runtime): remove unused preview APIs (#4158)
add e5205619a refactor(storage): add context offload root authority (#4165)
new d107eac43 feat(ui): render session attachment images in Markdown
new d79c4b83f chore(ui): align attachment image surface metadata
new f37399a97 fix: harden session attachment image loading
new 52eaba037 fix(runtime): preserve attachment refs in conversation copies
new c4bd0c391 fix(ui): retry transient attachment image reads
new c7c12344f fix: enforce one attachment image preview policy
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (eea2d68e3)
\
N -- N -- N refs/heads/feat/4093-markdown-attachment-images (c7c12344f)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 6 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../tool-runtime-sandbox-boundary.test.ts | 6 +-
packages/runtime/src/bots/bot-registry.ts | 6 +-
packages/runtime/src/builtin-tools.ts | 4 +-
packages/runtime/src/tool-runtime.ts | 17 +-
.../src/__tests__/context-offload-store.test.ts | 228 ++++++++++++++++++
packages/storage/src/context-offload-store.ts | 264 +++++++++++++++++++++
.../storage/src/sqlite-context-offload-store.ts | 1 +
7 files changed, 504 insertions(+), 22 deletions(-)
create mode 100644 packages/storage/src/__tests__/context-offload-store.test.ts
create mode 100644 packages/storage/src/context-offload-store.ts
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/06: feat(ui): render session attachment images in Markdown
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/4093-markdown-attachment-images
in repository https://gitbox.apache.org/repos/asf/maka.git
commit d107eac43c48258846ea7d6dd0723102e0a0860e
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Aug 29 15:03:09 2026 +0800
feat(ui): render session attachment images in Markdown
Route every Astryx Markdown image through Maka's component policy, resolve canonical session attachment refs through the existing Runtime Host byte reader, and keep all other schemes inert. Share the session-scoped loader with user attachment thumbnails, remove the old reader prop chain and pre-parse image scanner, and tell the model which attachment ref is a valid Markdown image source. Add streaming, settled, missing-attachment, scheme-policy, model-history, and Storybook coverage.
Generated-by: Codex
---
.../src/__tests__/model-history-attachment.test.ts | 61 +++++++
packages/runtime/src/model-history.ts | 3 +
.../ui/src/__tests__/attachment-image.test.tsx | 180 +++++++++++++++++++++
packages/ui/src/__tests__/markdown-body.test.ts | 14 ++
packages/ui/src/attachment-image.tsx | 88 ++++++++++
packages/ui/src/chat-turn.tsx | 54 ++-----
packages/ui/src/chat-view.tsx | 23 +--
packages/ui/src/markdown-body.tsx | 108 +++----------
packages/ui/src/styles.css | 11 ++
packages/ui/stories/attachment.stories.tsx | 24 +++
10 files changed, 424 insertions(+), 142 deletions(-)
diff --git a/packages/runtime/src/__tests__/model-history-attachment.test.ts b/packages/runtime/src/__tests__/model-history-attachment.test.ts
new file mode 100644
index 000000000..069cf21f2
--- /dev/null
+++ b/packages/runtime/src/__tests__/model-history-attachment.test.ts
@@ -0,0 +1,61 @@
+/*
+ * 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 type { RuntimeEvent } from '@maka/core/runtime-event';
+import { buildRuntimeEventModelReplayPlan } from '../model-history.js';
+
+test('tells the model that a session image ref is a Markdown image source', () => {
+ const event: RuntimeEvent = {
+ id: 'event-1',
+ invocationId: 'invocation-1',
+ runId: 'run-1',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ ts: 1,
+ partial: false,
+ role: 'user',
+ author: 'user',
+ content: {
+ kind: 'text',
+ text: 'show this',
+ attachments: [
+ {
+ kind: 'image',
+ name: 'preview.png',
+ mimeType: 'image/png',
+ bytes: 3,
+ ref: {
+ kind: 'session_file',
+ sessionId: 'session-1',
+ relativePath: 'attachment-123',
+ },
+ },
+ ],
+ },
+ };
+
+ const item = buildRuntimeEventModelReplayPlan([event]).items[0];
+ assert.equal(item?.kind, 'text');
+ assert.match(
+ item.content,
+ /Markdown image source: "maka:\/\/runtime\/attachments\/attachment-123"/,
+ );
+});
diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts
index 9793c5bba..0ead934b5 100644
--- a/packages/runtime/src/model-history.ts
+++ b/packages/runtime/src/model-history.ts
@@ -941,6 +941,9 @@ function formatAttachmentRefs(attachments: readonly AttachmentRef[]): string {
? resourceRef
? [
`Read argument: ${JSON.stringify(readArgument)}`,
+ ...(attachment.kind === 'image'
+ ? [`Markdown image source: ${JSON.stringify(resourceRef)}`]
+ : []),
'This is a Session resource, not a workspace file. Use the ref above; never use the display name as a path.',
].join('\n')
: `Read argument: ${JSON.stringify(readArgument)}`
diff --git a/packages/ui/src/__tests__/attachment-image.test.tsx b/packages/ui/src/__tests__/attachment-image.test.tsx
new file mode 100644
index 000000000..3d66ac365
--- /dev/null
+++ b/packages/ui/src/__tests__/attachment-image.test.tsx
@@ -0,0 +1,180 @@
+/*
+ * 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 { afterEach, test } from 'node:test';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { parseHTML } from 'linkedom';
+import { TurnView } from '../chat-turn.js';
+import { SessionAttachmentProvider } from '../attachment-image.js';
+import { LocaleProvider } from '../locale-context.js';
+import { MarkdownBody } from '../markdown-body.js';
+import type { TurnViewModel } from '../materialize.js';
+
+const originalGlobals = {
+ document: globalThis.document,
+ matchMedia: globalThis.matchMedia,
+ requestAnimationFrame: globalThis.requestAnimationFrame,
+ cancelAnimationFrame: globalThis.cancelAnimationFrame,
+ window: globalThis.window,
+};
+const originalActEnvironment = (globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
+}).IS_REACT_ACT_ENVIRONMENT;
+const mountedRoots: ReturnType<typeof createRoot>[] = [];
+
+afterEach(async () => {
+ for (const root of mountedRoots.splice(0)) await act(() => root.unmount());
+ Object.assign(globalThis, {
+ ...originalGlobals,
+ IS_REACT_ACT_ENVIRONMENT: originalActEnvironment,
+ });
+});
+
+function domRoot() {
+ const { document, window } = parseHTML('<div id="root"></div>');
+ 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 };
+}
+
+const TURN_WITH_IMAGE: TurnViewModel = {
+ turnId: 'turn-1',
+ status: 'completed',
+ partialOutputRetained: false,
+ user: {
+ id: 'ask',
+ role: 'user',
+ text: 'show this',
+ ts: 1,
+ attachments: [{
+ kind: 'image',
+ name: 'preview.png',
+ mimeType: 'image/png',
+ bytes: 3,
+ ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-123' },
+ }],
+ },
+ tools: [],
+ notes: [],
+ startedAt: 1,
+ timeline: [],
+};
+
+test('loads a user attachment thumbnail through the injected session reader', async () => {
+ const { container, root } = domRoot();
+ await act(async () => {
+ root.render(
+ <LocaleProvider locale="en">
+ <SessionAttachmentProvider
+ sessionId="session-1"
+ readBytes={async () => ({
+ ok: true,
+ base64: 'aW1n',
+ mimeType: 'image/png',
+ })}
+ >
+ <TurnView turn={TURN_WITH_IMAGE} />
+ </SessionAttachmentProvider>
+ </LocaleProvider>,
+ );
+ });
+
+ const image = container.querySelector('.maka-user-attachment-thumbnail img');
+ assert.ok(image);
+ assert.equal(image.getAttribute('src'), 'data:image/png;base64,aW1n');
+});
+
+test('renders a session attachment referenced by assistant Markdown', async () => {
+ const { container, root } = domRoot();
+ let readRef: { sessionId: string; artifactId: string } | undefined;
+ await act(async () => {
+ root.render(
+ <SessionAttachmentProvider
+ sessionId="session-1"
+ readBytes={async (sessionId, artifactId) => {
+ readRef = { sessionId, artifactId };
+ return {
+ ok: true,
+ base64: 'aW1n',
+ mimeType: 'image/png',
+ };
+ }}
+ >
+ <MarkdownBody text="" />
+ </SessionAttachmentProvider>,
+ );
+ });
+
+ const image = container.querySelector('img[alt="preview"]');
+ assert.ok(image);
+ assert.equal(image.getAttribute('src'), 'data:image/png;base64,aW1n');
+ assert.deepEqual(readRef, { sessionId: 'session-1', artifactId: 'attachment-123' });
+});
+
+test('keeps an unknown assistant attachment as a named placeholder', async () => {
+ const { container, root } = domRoot();
+ await act(async () => {
+ root.render(
+ <SessionAttachmentProvider
+ sessionId="session-1"
+ readBytes={async () => ({ ok: false })}
+ >
+ <MarkdownBody text="" />
+ </SessionAttachmentProvider>,
+ );
+ });
+
+ assert.equal(container.querySelector('img'), null);
+ assert.match(container.textContent, /\[missing\]/);
+});
+
+test('renders a restored attachment through the streaming Markdown path', async () => {
+ const { container, root } = domRoot();
+ const markdown = '';
+ await act(async () => {
+ root.render(
+ <SessionAttachmentProvider
+ sessionId="session-1"
+ readBytes={async () => ({
+ ok: true,
+ base64: 'c3RyZWFt',
+ mimeType: 'image/png',
+ })}
+ >
+ <MarkdownBody text={markdown} streaming settledText={markdown} />
+ </SessionAttachmentProvider>,
+ );
+ });
+
+ const image = container.querySelector('img[alt="preview"]');
+ assert.ok(image);
+ assert.equal(image.getAttribute('src'), 'data:image/png;base64,c3RyZWFt');
+});
diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts
index 5ff813f70..c38ec386c 100644
--- a/packages/ui/src/__tests__/markdown-body.test.ts
+++ b/packages/ui/src/__tests__/markdown-body.test.ts
@@ -283,6 +283,8 @@ it('never loads non-allowlisted Markdown image sources', () => {
'',
'caption ',
'',
+ '',
+ '',
'![reference][avatar]',
'',
'[avatar]: file:///Users/example/private.png',
@@ -299,6 +301,8 @@ it('does not treat navigation and communication schemes as image resources', ()
'MAKA://auth/login',
'maka://settings/models',
'maka://compose?text=hello',
+ 'maka://runtime/attachments/attachment-123?session=other',
+ 'maka://runtime/attachments/not-an-artifact',
'mailto:user@example.com',
]) {
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
@@ -310,6 +314,16 @@ it('does not treat navigation and communication schemes as image resources', ()
}
});
+it('shows an attachment placeholder when no session reader is installed', () => {
+ const markup = renderToStaticMarkup(createElement(MarkdownBody, {
+ text: '',
+ }));
+
+ assert.match(markup, />\[preview\]</);
+ assert.doesNotMatch(markup, /maka:\/\/runtime\/attachments/);
+ assert.doesNotMatch(markup, /<img\b/);
+});
+
it('defers Mermaid fences beyond the per-Markdown automatic diagram budget', () => {
const fence = (index: number) => [
'```mermaid',
diff --git a/packages/ui/src/attachment-image.tsx b/packages/ui/src/attachment-image.tsx
new file mode 100644
index 000000000..63f94316d
--- /dev/null
+++ b/packages/ui/src/attachment-image.tsx
@@ -0,0 +1,88 @@
+/*
+ * 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 {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+
+/** Host capability for reading bytes from the Runtime Host attachment authority. */
+export type ReadAttachmentBytes = (
+ sessionId: string,
+ artifactId: string,
+) => Promise<{ ok: true; base64: string; mimeType: string } | { ok: false }>;
+
+type SessionAttachmentContextValue = {
+ sessionId: string;
+ readBytes: ReadAttachmentBytes;
+};
+
+const SessionAttachmentContext = createContext<SessionAttachmentContextValue | undefined>(undefined);
+
+/** Installs the one session-scoped attachment reader used by every transcript image. */
+export function SessionAttachmentProvider(props: {
+ sessionId: string;
+ readBytes?: ReadAttachmentBytes;
+ children: ReactNode;
+}) {
+ const value = useMemo(
+ () => props.readBytes
+ ? { sessionId: props.sessionId, readBytes: props.readBytes }
+ : undefined,
+ [props.readBytes, props.sessionId],
+ );
+ return (
+ <SessionAttachmentContext.Provider value={value}>
+ {props.children}
+ </SessionAttachmentContext.Provider>
+ );
+}
+
+/** Resolve a session attachment to an internal data URL without exposing host globals. */
+export function useAttachmentImageSource(ref: {
+ artifactId: string;
+ sessionId?: string;
+} | undefined): string | undefined {
+ const context = useContext(SessionAttachmentContext);
+ const artifactId = ref?.artifactId;
+ const sessionId = ref?.sessionId ?? context?.sessionId;
+ const readBytes = context?.readBytes;
+ const [src, setSrc] = useState<string | undefined>(undefined);
+
+ useEffect(() => {
+ setSrc(undefined);
+ if (!artifactId || !sessionId || !readBytes) return;
+ let cancelled = false;
+ readBytes(sessionId, artifactId)
+ .then((result) => {
+ if (cancelled || !result.ok) return;
+ setSrc(`data:${result.mimeType};base64,${result.base64}`);
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [artifactId, readBytes, sessionId]);
+
+ return src;
+}
diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx
index a0253207c..540dfcf86 100644
--- a/packages/ui/src/chat-turn.tsx
+++ b/packages/ui/src/chat-turn.tsx
@@ -71,6 +71,7 @@ import { getConversationCopy } from './conversation-copy.js';
import { AstryxLocaleProvider } from './astryx-i18n.js';
import { InlineReferenceText } from './inline-reference.js';
import { redactSecrets } from './redact.js';
+import { useAttachmentImageSource } from './attachment-image.js';
export function LocalizedChatMessage({
accessibleLabel,
@@ -89,19 +90,6 @@ export function LocalizedChatMessage({
);
}
-/**
- * Injected host capability that reads a session attachment's bytes. @maka/ui is
- * host-agnostic: it never reaches into the desktop preload or any other host
- * global. The desktop renderer threads its attachment reader through this prop;
- * non-desktop hosts (Storybook, tests, a future web shell) can omit it or supply
- * their own reader,
- * in which case an image attachment stays in its pending skeleton.
- */
-export type ReadAttachmentBytes = (
- sessionId: string,
- relativePath: string,
-) => Promise<{ ok: true; base64: string; mimeType: string } | { ok: false }>;
-
function legacySentSkillTokens(text: string) {
const values = new Set(
[...text.matchAll(new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'))].map((match) => match[0]),
@@ -109,25 +97,14 @@ function legacySentSkillTokens(text: string) {
return [...values].map((value) => ({ value, label: value, variant: 'neutral' as const }));
}
-function AttachmentImage(props: { attachment: AttachmentRef; onReadAttachmentBytes?: ReadAttachmentBytes }) {
- const [src, setSrc] = useState<string | undefined>(undefined);
- const { onReadAttachmentBytes } = props;
- useEffect(() => {
- if (props.attachment.ref.kind !== 'session_file') return;
- // No host reader (non-desktop host, or the capability wasn't wired): leave the
- // thumbnail in its pending skeleton rather than reaching into a host global.
- if (!onReadAttachmentBytes) return;
- let cancelled = false;
- onReadAttachmentBytes(props.attachment.ref.sessionId, props.attachment.ref.relativePath)
- .then((result) => {
- if (cancelled || !result.ok) return;
- setSrc(`data:${result.mimeType};base64,${result.base64}`);
- })
- .catch(() => {});
- return () => {
- cancelled = true;
- };
- }, [props.attachment, onReadAttachmentBytes]);
+function AttachmentImage(props: { attachment: AttachmentRef }) {
+ const ref = props.attachment.ref.kind === 'session_file'
+ ? {
+ sessionId: props.attachment.ref.sessionId,
+ artifactId: props.attachment.ref.relativePath,
+ }
+ : undefined;
+ const src = useAttachmentImageSource(ref);
if (!src) {
return (
<Thumbnail
@@ -171,7 +148,6 @@ const UserMessageBody = memo(function UserMessageBody(props: {
attachments?: readonly AttachmentRef[];
quotes?: readonly QuoteRef[];
inlineReferences?: readonly InlineReference[];
- onReadAttachmentBytes?: ReadAttachmentBytes;
/** When set on a user message, show an edit affordance that starts a revision draft. */
onEditUserMessage?: () => void;
editDisabled?: boolean;
@@ -254,7 +230,6 @@ const UserMessageBody = memo(function UserMessageBody(props: {
<AttachmentImage
key={`${attachment.name}-${index}`}
attachment={attachment}
- onReadAttachmentBytes={props.onReadAttachmentBytes}
/>
))}
</HStack>
@@ -277,7 +252,6 @@ const UserMessageBody = memo(function UserMessageBody(props: {
export function TransientUserMessage(props: {
message: TransientUserMessageProjection;
- onReadAttachmentBytes?: ReadAttachmentBytes;
}) {
const copy = getConversationCopy(useUiLocale()).messages;
const message = props.message;
@@ -295,7 +269,6 @@ export function TransientUserMessage(props: {
attachments={message.attachments}
quotes={message.quotes}
inlineReferences={message.inlineReferences}
- onReadAttachmentBytes={props.onReadAttachmentBytes}
/>
</LocalizedChatMessage>
</div>
@@ -455,13 +428,6 @@ export const TurnView = memo(function TurnView(props: {
providerRetry?: LiveProviderRetry;
initialLiveContent?: ReadonlyMap<string, string>;
};
- /**
- * Injected host reader for image attachment bytes. Threaded down to the user
- * message's `AttachmentImage` thumbnails; absent on non-desktop hosts, where
- * image thumbnails stay in their pending skeleton. Keeps @maka/ui from
- * reaching into the desktop preload directly.
- */
- onReadAttachmentBytes?: ReadAttachmentBytes;
/**
* Open a linked subagent child session in the main chat column. Threaded into
* linked subagent tool rows; omitted when the host has no navigation.
@@ -580,7 +546,6 @@ export const TurnView = memo(function TurnView(props: {
attachments={turn.user.attachments}
quotes={turn.user.quotes}
inlineReferences={turn.user.inlineReferences}
- onReadAttachmentBytes={props.onReadAttachmentBytes}
onEditUserMessage={
props.onEditUserMessage && !turn.user.hostOrigin
? () => props.onEditUserMessage?.(turn.turnId)
@@ -636,7 +601,6 @@ export const TurnView = memo(function TurnView(props: {
attachments={message.attachments}
quotes={message.quotes}
inlineReferences={message.inlineReferences}
- onReadAttachmentBytes={props.onReadAttachmentBytes}
/>
</LocalizedChatMessage>
);
diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx
index 2efc296d3..64f3b42e8 100644
--- a/packages/ui/src/chat-view.tsx
+++ b/packages/ui/src/chat-view.tsx
@@ -53,7 +53,6 @@ import {
TurnRunningStatus,
TurnView,
TransientUserMessage,
- type ReadAttachmentBytes,
type TurnFooterActionMeta,
type TurnPresentationDeriver,
} from './chat-turn.js';
@@ -63,6 +62,10 @@ import { placeChatConversationItems } from './chat-conversation-items.js';
import { useUiLocale } from './locale-context.js';
import { getConversationCopy } from './conversation-copy.js';
import { SessionContextLayer, type SessionContextGoal } from './session-context-layer.js';
+import {
+ SessionAttachmentProvider,
+ type ReadAttachmentBytes,
+} from './attachment-image.js';
export interface LiveContentActivationSnapshot {
turnId: string;
@@ -660,11 +663,15 @@ export function ChatView(props: {
);
return (
- <section
- className="maka-main agents-chat-panel agents-chat-view-root"
- role="region"
- aria-label={copy.conversationAriaLabel(props.activeSession.name)}
+ <SessionAttachmentProvider
+ sessionId={props.activeSession.id}
+ readBytes={props.onReadAttachmentBytes}
>
+ <section
+ className="maka-main agents-chat-panel agents-chat-view-root"
+ role="region"
+ aria-label={copy.conversationAriaLabel(props.activeSession.name)}
+ >
{props.returnToLatest ? (
<TranscriptHistoryNotice
title={props.returnToLatest.title}
@@ -733,7 +740,6 @@ export function ChatView(props: {
<TransientUserMessage
key={message.id}
message={message}
- onReadAttachmentBytes={props.onReadAttachmentBytes}
/>
))
: null}
@@ -757,7 +763,6 @@ export function ChatView(props: {
: undefined}
lineageBadges={turnPresentation?.lineageBadgesByTurn[turn.turnId]}
onLineageBadgeClick={stableLineageBadgeClick}
- onReadAttachmentBytes={props.onReadAttachmentBytes}
onOpenLinkedSession={
props.onOpenLinkedSession ? stableOpenLinkedSession : undefined
}
@@ -800,7 +805,6 @@ export function ChatView(props: {
<TransientUserMessage
key={message.id}
message={message}
- onReadAttachmentBytes={props.onReadAttachmentBytes}
/>
))}
{/* #642 fallback: streaming began before the optimistic user turn
@@ -890,7 +894,8 @@ export function ChatView(props: {
)
) : null}
</div>
- </section>
+ </section>
+ </SessionAttachmentProvider>
);
}
diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx
index bdc08ae4d..1687ae12b 100644
--- a/packages/ui/src/markdown-body.tsx
+++ b/packages/ui/src/markdown-body.tsx
@@ -47,6 +47,8 @@ import { useUiLocale } from './locale-context.js';
import { getSharedUiCopy } from './shared-ui-copy.js';
import { MermaidDiagram } from './mermaid-diagram.js';
import { prepareMarkdownMath } from './markdown-math.js';
+import { parseAttachmentResourceRef } from '@maka/core/attachments';
+import { useAttachmentImageSource } from './attachment-image.js';
const BASE_MARKDOWN_COMPONENTS = {
link: MarkdownLink,
@@ -130,10 +132,7 @@ export function MarkdownBody(props: {
settledText?: string;
density?: 'default' | 'compact';
}) {
- const source = neutralizeUnsafeMarkdownImages(props.text);
- const settledSource =
- props.settledText === undefined ? undefined : neutralizeUnsafeMarkdownImages(props.settledText);
- const prepared = prepareMarkdownMath(source, settledSource);
+ const prepared = prepareMarkdownMath(props.text, props.settledText);
const safeText = prepared.text;
const budgetedText = props.streaming ? safeText : applyMermaidRenderBudget(safeText);
const density = props.density ?? 'default';
@@ -250,94 +249,27 @@ function MarkdownCode(props: {
}
function MarkdownImage(props: { src: string; alt: string }) {
+ const attachment = parseAttachmentResourceRef(props.src);
+ const attachmentSrc = useAttachmentImageSource(
+ attachment ? { artifactId: attachment.artifactId } : undefined,
+ );
+ if (attachment) {
+ if (!attachmentSrc) return <span>[{props.alt}]</span>;
+ return (
+ <img
+ className="maka-markdown-attachment-image"
+ src={attachmentSrc}
+ alt={props.alt}
+ />
+ );
+ }
if (!isSafeMarkdownImageUrl(props.src)) return <span>[{props.alt}]</span>;
- // Astryx calls this component only for images inside a paragraph. The shared
- // reset makes bare images block-level, so preserve inline flow for badges and
- // sentence-level icons; the reset keeps max-width/height.
+ // Remote images can be badges or sentence-level icons, so preserve Maka's
+ // existing inline presentation. Session attachments above are content
+ // previews and deliberately own a block presentation instead.
return <img src={props.src} alt={props.alt} style={{ display: 'inline-block' }} />;
}
-/**
- * Astryx delegates inline images to `components.image`, but its current
- * standalone-image branch renders a native `<img>` directly. Neutralize
- * unsafe direct-image syntax before parsing so both branches retain Maka's
- * existing closed URL allowlist. The scanner follows Astryx's image grammar
- * and leaves fenced/inline code unchanged.
- */
-function neutralizeUnsafeMarkdownImages(source: string): string {
- let fence: string | null = null;
- return source
- .split('\n')
- .map((line) => {
- if (fence) {
- if (line.startsWith(fence)) fence = null;
- return line;
- }
-
- const fenceMatch = line.match(/^(`{3,}|~{3,})(\w*)/);
- if (fenceMatch) {
- fence = fenceMatch[1];
- return line;
- }
-
- return neutralizeUnsafeImagesInLine(line);
- })
- .join('\n');
-}
-
-function neutralizeUnsafeImagesInLine(line: string): string {
- let output = '';
- let cursor = 0;
-
- while (cursor < line.length) {
- if (line[cursor] === '`') {
- const tickCount = line[cursor + 1] === '`'
- ? line[cursor + 2] === '`' ? 3 : 2
- : 1;
- const delimiter = '`'.repeat(tickCount);
- const close = line.indexOf(delimiter, cursor + tickCount);
- if (close !== -1) {
- const end = close + tickCount;
- output += line.slice(cursor, end);
- cursor = end;
- continue;
- }
- }
-
- if (line[cursor] === '!' && line[cursor + 1] === '[') {
- const altClose = line.indexOf(']', cursor + 2);
- if (altClose !== -1 && line[altClose + 1] === '(') {
- const srcStart = altClose + 2;
- const srcClose = findClosingParen(line, srcStart);
- if (srcClose !== -1) {
- const src = line.slice(srcStart, srcClose);
- if (isSafeMarkdownImageUrl(src)) {
- output += line.slice(cursor, srcClose + 1);
- } else {
- output += `!\\[${line.slice(cursor + 2, srcClose + 1)}`;
- }
- cursor = srcClose + 1;
- continue;
- }
- }
- }
-
- output += line[cursor];
- cursor++;
- }
-
- return output;
-}
-
-function findClosingParen(text: string, start: number): number {
- let depth = 1;
- for (let index = start; index < text.length; index++) {
- if (text[index] === '(') depth++;
- if (text[index] === ')' && --depth === 0) return index;
- }
- return -1;
-}
-
function isSafeMarkdownImageUrl(url: string): boolean {
try {
const protocol = new URL(url).protocol;
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index be419c8c3..f768fa45d 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -461,6 +461,17 @@
color: var(--foreground-secondary);
}
+/* Astryx hands custom images no inline/block placement or spacing wrapper.
+ Session artifacts are content previews rather than sentence badges, so they
+ own a bounded block presentation; compact transcript rhythm resets the
+ top-level margin above through the shared adjacency rules. */
+.maka-markdown-attachment-image {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin-block: var(--space-3) var(--space-4);
+}
+
/* Markdown code renderers are custom components so Mermaid can be loaded only
for settled Mermaid fences. Astryx skips its own spacing wrapper when
`components.code` is set, so the custom block sits directly in the document
diff --git a/packages/ui/stories/attachment.stories.tsx b/packages/ui/stories/attachment.stories.tsx
index f49aca2cb..f0cd66814 100644
--- a/packages/ui/stories/attachment.stories.tsx
+++ b/packages/ui/stories/attachment.stories.tsx
@@ -200,6 +200,30 @@ export const ImageThumbnails: Story = {
),
};
+// Real path: the assistant cites a Session attachment ref in Markdown → the
+// chat's session-scoped reader resolves it through the same image authority as
+// the sent-message thumbnail above.
+export const AssistantMarkdownImage: Story = {
+ render: () => (
+ <Frame>
+ <AttachmentChat
+ {...baseChat}
+ messages={[
+ user('u-markdown', 't-markdown', '把这张图显示在回答里。', []),
+ {
+ type: 'assistant',
+ id: 'a-markdown',
+ turnId: 't-markdown',
+ ts: NOW + 1,
+ text: '会话附件预览:\n\n',
+ modelId: 'claude-sonnet-4-5',
+ },
+ ]}
+ />
+ </Frame>
+ ),
+};
+
// Real path: send one prompt with every durable reference kind → file tokens sit
// above the bubble, while inline Skill/file tokens, quote and image keep their own hierarchy.
// The narrow frame verifies wrapping without inventing a second product layout.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 05/06: fix(ui): retry transient attachment image reads
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/4093-markdown-attachment-images
in repository https://gitbox.apache.org/repos/asf/maka.git
commit c4bd0c391065dc8eb5698ce1839a0ebd316e61d2
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Aug 29 17:52:26 2026 +0800
fix(ui): retry transient attachment image reads
Generated-by: Codex
---
.../ui/src/__tests__/attachment-image.test.tsx | 120 +++++++++++++--------
packages/ui/src/attachment-image.tsx | 34 +++---
packages/ui/src/markdown-body.tsx | 1 +
3 files changed, 93 insertions(+), 62 deletions(-)
diff --git a/packages/ui/src/__tests__/attachment-image.test.tsx b/packages/ui/src/__tests__/attachment-image.test.tsx
index ce9c23afa..ba13594b5 100644
--- a/packages/ui/src/__tests__/attachment-image.test.tsx
+++ b/packages/ui/src/__tests__/attachment-image.test.tsx
@@ -77,7 +77,7 @@ async function renderAttachmentMarkdown(text: string, readBytes: ReadAttachmentB
</SessionAttachmentProvider>,
);
});
- return container;
+ return { container, root };
}
const TURN_WITH_IMAGE: TurnViewModel = {
@@ -103,8 +103,9 @@ const TURN_WITH_IMAGE: TurnViewModel = {
timeline: [],
};
-test('loads a user attachment thumbnail through the injected session reader', async () => {
+test('keeps the existing user thumbnail payload contract', async () => {
const { container, root } = domRoot();
+ const base64 = 'a'.repeat(3 * 1024 * 1024);
await act(async () => {
root.render(
<LocaleProvider locale="en">
@@ -112,7 +113,7 @@ test('loads a user attachment thumbnail through the injected session reader', as
sessionId="session-1"
readBytes={async () => ({
ok: true,
- base64: 'aW1n',
+ base64,
mimeType: 'image/png',
})}
>
@@ -124,12 +125,12 @@ test('loads a user attachment thumbnail through the injected session reader', as
const image = container.querySelector('.maka-user-attachment-thumbnail img');
assert.ok(image);
- assert.equal(image.getAttribute('src'), 'data:image/png;base64,aW1n');
+ assert.equal(image.getAttribute('src'), `data:image/png;base64,${base64}`);
});
test('renders a session attachment referenced by assistant Markdown', async () => {
let readRef: { sessionId: string; artifactId: string } | undefined;
- const container = await renderAttachmentMarkdown(
+ const { container } = await renderAttachmentMarkdown(
'',
async (sessionId, artifactId) => {
readRef = { sessionId, artifactId };
@@ -143,43 +144,32 @@ test('renders a session attachment referenced by assistant Markdown', async () =
assert.deepEqual(readRef, { sessionId: 'session-1', artifactId: 'attachment-123' });
});
-test('keeps an unknown assistant attachment as a named placeholder', async () => {
- const container = await renderAttachmentMarkdown(
- '',
- async () => ({ ok: false, reason: 'not_found' }),
- );
-
- assert.equal(container.querySelector('img'), null);
- assert.match(container.textContent, /\[missing\]/);
-});
-
-test('keeps a non-image assistant attachment as a named placeholder', async () => {
- const container = await renderAttachmentMarkdown(
- '',
- async () => ({ ok: true, base64: 'cGRm', mimeType: 'application/pdf' }),
- );
-
- assert.equal(container.querySelector('img'), null);
- assert.match(container.textContent, /\[document\]/);
-});
-
-test('keeps an oversized assistant image out of renderer state', async () => {
- const container = await renderAttachmentMarkdown(
- '',
- async () => ({
- ok: true,
- base64: 'a'.repeat(3 * 1024 * 1024),
- mimeType: 'image/png',
- }),
- );
-
- assert.equal(container.querySelector('img'), null);
- assert.match(container.textContent, /\[large\]/);
+test('keeps unreadable assistant attachments as named placeholders', async () => {
+ const cases: Array<[string, ReadAttachmentBytes]> = [
+ ['missing', async () => ({ ok: false, reason: 'not_found' })],
+ ['document', async () => ({ ok: true, base64: 'cGRm', mimeType: 'application/pdf' })],
+ [
+ 'large',
+ async () => ({
+ ok: true,
+ base64: 'a'.repeat(3 * 1024 * 1024),
+ mimeType: 'image/png',
+ }),
+ ],
+ ];
+ for (const [name, readBytes] of cases) {
+ const { container } = await renderAttachmentMarkdown(
+ ``,
+ readBytes,
+ );
+ assert.equal(container.querySelector('img'), null);
+ assert.ok(container.textContent.includes(`[${name}]`));
+ }
});
test('shares one attachment read across repeated Markdown image refs', async () => {
let reads = 0;
- const container = await renderAttachmentMarkdown(
+ const { container } = await renderAttachmentMarkdown(
[
'',
'',
@@ -194,19 +184,55 @@ test('shares one attachment read across repeated Markdown image refs', async ()
assert.equal(reads, 1);
});
-test('renders a restored attachment through the streaming Markdown path', async () => {
+test('retries an attachment image after a transient read failure', async () => {
+ const markdown = '';
+ let reads = 0;
+ const readBytes: ReadAttachmentBytes = async () => {
+ reads += 1;
+ return reads === 1
+ ? { ok: false, reason: 'read_failed' }
+ : { ok: true, base64: 'cmVjb3ZlcmVk', mimeType: 'image/png' };
+ };
+ const { container, root } = await renderAttachmentMarkdown(markdown, readBytes);
+ assert.equal(container.querySelector('img'), null);
+ await act(async () => {
+ root.render(
+ <SessionAttachmentProvider sessionId="session-1" readBytes={readBytes}>
+ <MarkdownBody key="retry" text={markdown} />
+ </SessionAttachmentProvider>,
+ );
+ });
+
+ const image = container.querySelector('img[alt="preview"]');
+ assert.ok(image);
+ assert.equal(image.getAttribute('src'), 'data:image/png;base64,cmVjb3ZlcmVk');
+ assert.equal(reads, 2);
+});
+
+test('renders an attachment when a streaming Markdown image becomes complete', async () => {
const { container, root } = domRoot();
const markdown = '';
+ const readBytes: ReadAttachmentBytes = async () => ({
+ ok: true,
+ base64: 'c3RyZWFt',
+ mimeType: 'image/png',
+ });
+ await act(async () => {
+ root.render(
+ <SessionAttachmentProvider sessionId="session-1" readBytes={readBytes}>
+ <MarkdownBody
+ text=";
+ });
+ assert.equal(container.querySelector('img'), null);
+
await act(async () => {
root.render(
- <SessionAttachmentProvider
- sessionId="session-1"
- readBytes={async () => ({
- ok: true,
- base64: 'c3RyZWFt',
- mimeType: 'image/png',
- })}
- >
+ <SessionAttachmentProvider sessionId="session-1" readBytes={readBytes}>
<MarkdownBody text={markdown} streaming settledText={markdown} />
</SessionAttachmentProvider>,
);
diff --git a/packages/ui/src/attachment-image.tsx b/packages/ui/src/attachment-image.tsx
index 65242316b..ea5bdaa9a 100644
--- a/packages/ui/src/attachment-image.tsx
+++ b/packages/ui/src/attachment-image.tsx
@@ -36,7 +36,7 @@ export type ReadAttachmentBytes = (
type SessionAttachmentContextValue = {
sessionId: string;
- loadImage: (sessionId: string, artifactId: string) => Promise<string | undefined>;
+ loadImage: (sessionId: string, artifactId: string) => Promise<ArtifactBinaryReadResult>;
};
const SessionAttachmentContext = createContext<SessionAttachmentContextValue | undefined>(undefined);
@@ -51,23 +51,20 @@ export function SessionAttachmentProvider(props: {
() => {
const readBytes = props.readBytes;
if (!readBytes) return undefined;
- const pending = new Map<string, Promise<string | undefined>>();
+ const pending = new Map<string, Promise<ArtifactBinaryReadResult>>();
return {
sessionId: props.sessionId,
loadImage(sessionId: string, artifactId: string) {
const key = `${sessionId}\0${artifactId}`;
const existing = pending.get(key);
if (existing) return existing;
- const loaded = readBytes(sessionId, artifactId)
- .then((result) => {
- if (!result.ok) return undefined;
- const outcome = decideImageReadOutcome(result);
- return outcome.kind === 'image'
- ? `data:${outcome.safeMime};base64,${outcome.base64}`
- : undefined;
- })
- .catch(() => undefined);
+ const loaded = readBytes(sessionId, artifactId).catch(
+ (): ArtifactBinaryReadResult => ({ ok: false, reason: 'read_failed' }),
+ );
pending.set(key, loaded);
+ void loaded.finally(() => {
+ if (pending.get(key) === loaded) pending.delete(key);
+ });
return loaded;
},
};
@@ -85,7 +82,7 @@ export function SessionAttachmentProvider(props: {
export function useAttachmentImageSource(ref: {
artifactId: string;
sessionId?: string;
-} | undefined): string | undefined {
+} | undefined, safePreview = false): string | undefined {
const context = useContext(SessionAttachmentContext);
const artifactId = ref?.artifactId;
const sessionId = ref?.sessionId ?? context?.sessionId;
@@ -97,13 +94,20 @@ export function useAttachmentImageSource(ref: {
if (!artifactId || !sessionId || !loadImage) return;
let cancelled = false;
loadImage(sessionId, artifactId)
- .then((loaded) => {
- if (!cancelled) setSrc(loaded);
+ .then((result) => {
+ if (cancelled || !result.ok) return;
+ const outcome = safePreview ? decideImageReadOutcome(result) : undefined;
+ if (safePreview && outcome?.kind !== 'image') return;
+ setSrc(
+ outcome?.kind === 'image'
+ ? `data:${outcome.safeMime};base64,${outcome.base64}`
+ : `data:${result.mimeType};base64,${result.base64}`,
+ );
})
return () => {
cancelled = true;
};
- }, [artifactId, loadImage, sessionId]);
+ }, [artifactId, loadImage, safePreview, sessionId]);
return src;
}
diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx
index 1687ae12b..50a5eedb5 100644
--- a/packages/ui/src/markdown-body.tsx
+++ b/packages/ui/src/markdown-body.tsx
@@ -252,6 +252,7 @@ function MarkdownImage(props: { src: string; alt: string }) {
const attachment = parseAttachmentResourceRef(props.src);
const attachmentSrc = useAttachmentImageSource(
attachment ? { artifactId: attachment.artifactId } : undefined,
+ true,
);
if (attachment) {
if (!attachmentSrc) return <span>[{props.alt}]</span>;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/06: chore(ui): align attachment image surface metadata
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/4093-markdown-attachment-images
in repository https://gitbox.apache.org/repos/asf/maka.git
commit d79c4b83ffbcf56829d2bccb256d4331bc10046b
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Aug 29 15:08:16 2026 +0800
chore(ui): align attachment image surface metadata
Generated-by: Codex
---
docs/astryx-surface-file-inventory.md | 3 ++-
docs/astryx-surface-file-inventory.paths | 1 +
packages/ui/src/chat-view.tsx | 9 +++------
3 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 1588a9edb..b0a9c70a1 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 221 files — blocker 0, polish 1, aligned 220.
+**Totals:** 222 files — blocker 0, polish 1, aligned 221.
## Exclusions (explicit)
@@ -190,6 +190,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/workhub-surface.tsx` | other | Button | aligned — uses Astryx (Button) | aligned |
| `packages/ui/src/astryx-chat-reasoning.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/astryx-i18n.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `packages/ui/src/attachment-image.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/attachment-kinds.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/bot-brand-logo.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/capability-audit-strip.tsx` | ui-composition | Banner | aligned — uses Astryx (Banner) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index f7dbe7e93..01833bc81 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -162,6 +162,7 @@ apps/desktop/src/renderer/work-board-panel.tsx
apps/desktop/src/renderer/workhub-surface.tsx
packages/ui/src/astryx-chat-reasoning.tsx
packages/ui/src/astryx-i18n.tsx
+packages/ui/src/attachment-image.tsx
packages/ui/src/attachment-kinds.tsx
packages/ui/src/bot-brand-logo.tsx
packages/ui/src/capability-audit-strip.tsx
diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx
index 64f3b42e8..9aea0fdfd 100644
--- a/packages/ui/src/chat-view.tsx
+++ b/packages/ui/src/chat-view.tsx
@@ -302,12 +302,9 @@ export function ChatView(props: {
};
onRevisionNavigate?: (sessionId: string) => void;
/**
- * Host reader for image attachment bytes, threaded to each turn's user-message
- * thumbnails. The desktop shell passes its preload `attachments.readBytes`;
- * non-desktop hosts omit it and image thumbnails stay in their pending
- * skeleton. Keeps @maka/ui host-agnostic with no direct host-global access.
- * Pass an identity-stable reference so the memoized TurnViews keep skipping
- * reconciliation on the hot streaming path.
+ * Host reader for image attachment bytes. The desktop shell passes its preload
+ * `attachments.readBytes`; non-desktop hosts may omit it. Keeps @maka/ui
+ * host-agnostic with no direct host-global access.
*/
onReadAttachmentBytes?: ReadAttachmentBytes;
/**
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 06/06: fix: enforce one attachment image preview policy
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/4093-markdown-attachment-images
in repository https://gitbox.apache.org/repos/asf/maka.git
commit c7c12344f4c7c66bdd29a6eb1c89e9dd68469243
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Aug 29 18:41:37 2026 +0800
fix: enforce one attachment image preview policy
Generated-by: Codex
---
.../__tests__/artifact-preview-registry.test.ts | 19 +++--
.../runtime-host-artifacts-ipc-main.test.ts | 86 +++++++++++++++++++-
.../src/main/runtime-host-artifacts-ipc-main.ts | 43 +++++++---
packages/core/src/artifacts.ts | 65 +++++++++++++++
.../ui/src/__tests__/attachment-image.test.tsx | 42 ++++++++--
packages/ui/src/artifact-preview-registry.ts | 93 ++++------------------
packages/ui/src/attachment-image.tsx | 30 ++++---
packages/ui/src/chat-turn.tsx | 11 ++-
packages/ui/src/markdown-body.tsx | 1 -
9 files changed, 266 insertions(+), 124 deletions(-)
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 609376de4..4ae9aa4b6 100644
--- a/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts
+++ b/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts
@@ -19,9 +19,11 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
-import type { ArtifactBinaryReadResult } from '@maka/core/artifacts';
import {
- IMAGE_PAYLOAD_MAX_BYTES,
+ ARTIFACT_IMAGE_PREVIEW_MAX_BYTES,
+ type ArtifactBinaryReadResult,
+} from '@maka/core/artifacts';
+import {
decideImageReadOutcome,
resolvePreviewKind,
} from '@maka/ui/artifact-preview-registry';
@@ -36,14 +38,17 @@ describe('artifact preview registry', () => {
it('enforces the inclusive metadata size boundary before loading', () => {
const base = { name: 'image.png', kind: 'image' as const, mimeType: 'image/png' };
- assert.deepEqual(resolvePreviewKind({ ...base, sizeBytes: IMAGE_PAYLOAD_MAX_BYTES }), {
+ assert.deepEqual(resolvePreviewKind({ ...base, sizeBytes: ARTIFACT_IMAGE_PREVIEW_MAX_BYTES }), {
kind: 'image',
reason: 'mime_match',
});
- assert.deepEqual(resolvePreviewKind({ ...base, sizeBytes: IMAGE_PAYLOAD_MAX_BYTES + 1 }), {
- kind: 'unsupported',
- reason: 'oversize',
- });
+ assert.deepEqual(
+ resolvePreviewKind({ ...base, sizeBytes: ARTIFACT_IMAGE_PREVIEW_MAX_BYTES + 1 }),
+ {
+ kind: 'unsupported',
+ reason: 'oversize',
+ },
+ );
});
it('routes IPC failures and malformed successful payloads without retaining base64', () => {
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 b5023bd22..6c452d592 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
@@ -25,6 +25,51 @@ import { test } from "node:test";
import { registerRuntimeHostArtifactsIpc } from "../runtime-host-artifacts-ipc-main.js";
type Handler = (event: unknown, ...args: any[]) => unknown;
+type StreamArtifact = (
+ sessionId: string,
+ artifactId: string,
+ writeChunk: (chunk: Uint8Array) => Promise<void>,
+) => Promise<number>;
+
+function previewArtifact(overrides: Record<string, unknown> = {}): Record<string, unknown> {
+ return {
+ id: "artifact-1",
+ sessionId: "session-1",
+ turnId: "turn-1",
+ createdAt: 1,
+ name: "preview.png",
+ kind: "image",
+ sizeBytes: 4,
+ mimeType: "image/png",
+ status: "live",
+ ...overrides,
+ };
+}
+
+function attachmentReadHandler(
+ artifact: Record<string, unknown>,
+ streamArtifact: StreamArtifact,
+): Handler {
+ const handlers = new Map<string, Handler>();
+ registerRuntimeHostArtifactsIpc({
+ ipcMain: {
+ handle: (channel, handler) => handlers.set(channel, handler as Handler),
+ },
+ client: {
+ hostEpoch: "host-1",
+ async getArtifact() {
+ return artifact;
+ },
+ streamArtifact,
+ } as never,
+ mainWindowController: {} as never,
+ sendToRenderer() {},
+ showItemInFolder() {},
+ });
+ const handler = handlers.get("attachments:readBytes");
+ assert.ok(handler);
+ return handler;
+}
test("Runtime Host Artifact IPC preserves previews and streams complete exports", async () => {
const root = await mkdtemp(join(tmpdir(), "maka-host-artifact-ipc-"));
@@ -40,8 +85,9 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports"
turnId: "turn-1",
createdAt: 1,
name: "result.bin",
- kind: "file",
+ kind: "image",
sizeBytes: content.byteLength,
+ mimeType: "image/png",
status: "live",
} as const;
const client = {
@@ -91,6 +137,14 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports"
await handlers.get("artifacts:readText")?.({}, "session-1", "artifact-1"),
{ ok: false, reason: "too_large" },
);
+ assert.deepEqual(
+ await handlers.get("attachments:readBytes")?.({}, "session-1", "artifact-1"),
+ {
+ ok: true,
+ base64: content.toString("base64"),
+ mimeType: "image/png",
+ },
+ );
assert.deepEqual(
await handlers.get("app:saveArtifactAs")?.({}, "session-1", "artifact-1"),
{ ok: true, saved: "result.bin" },
@@ -110,3 +164,33 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports"
await rm(root, { recursive: true, force: true });
}
});
+
+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"],
+ ] as const) {
+ let streamCalls = 0;
+ const read = attachmentReadHandler(previewArtifact(overrides), async () => {
+ streamCalls += 1;
+ return 0;
+ });
+ assert.deepEqual(await read({}, "session-1", overrides.id), { ok: false, reason });
+ assert.equal(streamCalls, 0);
+ }
+});
+
+test("Attachment byte IPC stops a stream that exceeds its preview admission", async () => {
+ const read = attachmentReadHandler(
+ previewArtifact({ id: "artifact-drifted" }),
+ async (_sessionId, _artifactId, writeChunk) => {
+ await writeChunk(new Uint8Array(2 * 1024 * 1024 + 1));
+ return 2 * 1024 * 1024 + 1;
+ },
+ );
+
+ assert.deepEqual(
+ await read({}, "session-1", "artifact-drifted"),
+ { ok: false, reason: "too_large" },
+ );
+});
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 5ba6f5ed7..dc8ed7008 100644
--- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
@@ -21,8 +21,12 @@ import { randomUUID } from "node:crypto";
import { open, mkdir, rename, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
-import { MAX_ATTACHMENT_BYTES } from '@maka/core/attachments';
-import { type ArtifactSaveResult } from '@maka/core/artifacts';
+import {
+ ARTIFACT_IMAGE_PREVIEW_MAX_BYTES,
+ normalizeArtifactImagePreviewMime,
+ resolveArtifactImagePreview,
+ type ArtifactSaveResult,
+} from '@maka/core/artifacts';
import { sanitizeArtifactName } from "@maka/storage/artifact-stores";
import {
handleReconnectableRead,
@@ -40,6 +44,8 @@ interface RuntimeHostArtifactsIpcDeps {
readonly presentationRoot?: string;
}
+const ATTACHMENT_PREVIEW_LIMIT_EXCEEDED = Symbol("attachment-preview-limit-exceeded");
+
export function registerRuntimeHostArtifactsIpc(
deps: RuntimeHostArtifactsIpcDeps,
): void {
@@ -93,27 +99,42 @@ export function registerRuntimeHostArtifactsIpc(
const artifact = await deps.client.getArtifact(sessionId, artifactId);
if (
!artifact ||
- artifact.status === "deleted" ||
- artifact.sizeBytes > MAX_ATTACHMENT_BYTES
+ artifact.status === "deleted"
) {
return { ok: false as const, reason: "not_found" };
}
+ const preview = resolveArtifactImagePreview(artifact);
+ if (preview.kind === "unsupported") {
+ return {
+ ok: false as const,
+ reason: preview.reason === "oversize" ? "too_large" : "unsupported_mime",
+ };
+ }
+ const mimeType = normalizeArtifactImagePreviewMime(artifact.mimeType, artifact.name);
+ if (!mimeType) return { ok: false as const, reason: "unsupported_mime" };
const chunks: Buffer[] = [];
let received = 0;
- await deps.client.streamArtifact(sessionId, artifactId, async (chunk) => {
- received += chunk.byteLength;
- if (received > MAX_ATTACHMENT_BYTES) {
- throw new Error("Attachment exceeds the renderer byte limit");
+ try {
+ await deps.client.streamArtifact(sessionId, artifactId, async (chunk) => {
+ received += chunk.byteLength;
+ if (received > ARTIFACT_IMAGE_PREVIEW_MAX_BYTES) {
+ throw ATTACHMENT_PREVIEW_LIMIT_EXCEEDED;
+ }
+ chunks.push(Buffer.from(chunk));
+ });
+ } catch (error) {
+ if (error === ATTACHMENT_PREVIEW_LIMIT_EXCEEDED) {
+ return { ok: false as const, reason: "too_large" };
}
- chunks.push(Buffer.from(chunk));
- });
+ throw error;
+ }
if (received !== artifact.sizeBytes) {
return { ok: false as const, reason: "read_failed" };
}
return {
ok: true as const,
base64: Buffer.concat(chunks, received).toString("base64"),
- mimeType: artifact.mimeType ?? "application/octet-stream",
+ mimeType,
};
},
);
diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts
index 0b068dcf0..02acd38da 100644
--- a/packages/core/src/artifacts.ts
+++ b/packages/core/src/artifacts.ts
@@ -21,6 +21,71 @@ export const ARTIFACT_KINDS = ['file', 'diff', 'html', 'image', 'pdf'] as const;
export type ArtifactKind = (typeof ARTIFACT_KINDS)[number];
+/** Maximum encoded image payload admitted to a renderer preview. */
+export const ARTIFACT_IMAGE_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
+
+const ARTIFACT_IMAGE_PREVIEW_MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.avif': 'image/avif',
+};
+
+const ARTIFACT_IMAGE_PREVIEW_MIMES = new Set(
+ Object.values(ARTIFACT_IMAGE_PREVIEW_MIME_BY_EXTENSION),
+);
+
+export interface ArtifactImagePreviewInput {
+ name: string;
+ kind: ArtifactKind;
+ mimeType?: string;
+ sizeBytes?: number;
+}
+
+export type ArtifactImagePreviewResolution =
+ | { kind: 'image'; reason: 'mime_match' | 'ext_fallback' }
+ | {
+ kind: 'unsupported';
+ reason: 'kind_disallowed' | 'mime_disallowed' | 'no_mime_no_ext' | 'oversize';
+ };
+
+/** Normalize the raster MIME admitted to renderer image previews. */
+export function normalizeArtifactImagePreviewMime(
+ mimeType: string | undefined,
+ name?: string,
+): string | null {
+ if (typeof mimeType === 'string' && mimeType.trim() !== '') {
+ const normalized = mimeType.trim().toLowerCase();
+ return ARTIFACT_IMAGE_PREVIEW_MIMES.has(normalized) ? normalized : null;
+ }
+ if (!name) return null;
+ const dot = name.lastIndexOf('.');
+ if (dot <= 0 || dot === name.length - 1) return null;
+ return ARTIFACT_IMAGE_PREVIEW_MIME_BY_EXTENSION[name.slice(dot).toLowerCase()] ?? null;
+}
+
+/** One metadata policy shared by preview admission and renderer presentation. */
+export function resolveArtifactImagePreview(
+ input: ArtifactImagePreviewInput,
+): ArtifactImagePreviewResolution {
+ if (input.kind !== 'image') {
+ return { kind: 'unsupported', reason: 'kind_disallowed' };
+ }
+ if (input.sizeBytes !== undefined && input.sizeBytes > ARTIFACT_IMAGE_PREVIEW_MAX_BYTES) {
+ return { kind: 'unsupported', reason: 'oversize' };
+ }
+ if (input.mimeType) {
+ return normalizeArtifactImagePreviewMime(input.mimeType)
+ ? { kind: 'image', reason: 'mime_match' }
+ : { kind: 'unsupported', reason: 'mime_disallowed' };
+ }
+ return normalizeArtifactImagePreviewMime(undefined, input.name)
+ ? { kind: 'image', reason: 'ext_fallback' }
+ : { kind: 'unsupported', reason: 'no_mime_no_ext' };
+}
+
export const ARTIFACT_SOURCES = [
'tool_result',
'tool_result_archive',
diff --git a/packages/ui/src/__tests__/attachment-image.test.tsx b/packages/ui/src/__tests__/attachment-image.test.tsx
index ba13594b5..d134ef831 100644
--- a/packages/ui/src/__tests__/attachment-image.test.tsx
+++ b/packages/ui/src/__tests__/attachment-image.test.tsx
@@ -103,19 +103,14 @@ const TURN_WITH_IMAGE: TurnViewModel = {
timeline: [],
};
-test('keeps the existing user thumbnail payload contract', async () => {
+test('renders a user thumbnail admitted by the shared preview policy', async () => {
const { container, root } = domRoot();
- const base64 = 'a'.repeat(3 * 1024 * 1024);
await act(async () => {
root.render(
<LocaleProvider locale="en">
<SessionAttachmentProvider
sessionId="session-1"
- readBytes={async () => ({
- ok: true,
- base64,
- mimeType: 'image/png',
- })}
+ readBytes={async () => ({ ok: true, base64: 'aW1n', mimeType: 'image/png' })}
>
<TurnView turn={TURN_WITH_IMAGE} />
</SessionAttachmentProvider>
@@ -125,7 +120,38 @@ test('keeps the existing user thumbnail payload contract', async () => {
const image = container.querySelector('.maka-user-attachment-thumbnail img');
assert.ok(image);
- assert.equal(image.getAttribute('src'), `data:image/png;base64,${base64}`);
+ assert.equal(image.getAttribute('src'), 'data:image/png;base64,aW1n');
+});
+
+test('rejects an oversized user thumbnail before reading it', async () => {
+ const { container, root } = domRoot();
+ let reads = 0;
+ await act(async () => {
+ root.render(
+ <LocaleProvider locale="en">
+ <SessionAttachmentProvider
+ sessionId="session-1"
+ readBytes={async () => {
+ reads += 1;
+ return { ok: false, reason: 'not_found' };
+ }}
+ >
+ <TurnView
+ turn={{
+ ...TURN_WITH_IMAGE,
+ user: {
+ ...TURN_WITH_IMAGE.user!,
+ attachments: [{ ...TURN_WITH_IMAGE.user!.attachments![0]!, bytes: 3 * 1024 * 1024 }],
+ },
+ }}
+ />
+ </SessionAttachmentProvider>
+ </LocaleProvider>,
+ );
+ });
+
+ assert.equal(container.querySelector('.maka-user-attachment-thumbnail img'), null);
+ assert.equal(reads, 0);
});
test('renders a session attachment referenced by assistant Markdown', async () => {
diff --git a/packages/ui/src/artifact-preview-registry.ts b/packages/ui/src/artifact-preview-registry.ts
index bf26062a4..def919807 100644
--- a/packages/ui/src/artifact-preview-registry.ts
+++ b/packages/ui/src/artifact-preview-registry.ts
@@ -19,54 +19,27 @@
/** Safe raster-image preview classification for renderer data URLs. */
-import type { ArtifactBinaryReadResult, ArtifactKind } from '@maka/core/artifacts';
+import {
+ ARTIFACT_IMAGE_PREVIEW_MAX_BYTES,
+ normalizeArtifactImagePreviewMime,
+ resolveArtifactImagePreview,
+ type ArtifactBinaryReadResult,
+ type ArtifactImagePreviewInput,
+ type ArtifactImagePreviewResolution,
+} from '@maka/core/artifacts';
import type { UiLocale } from '@maka/core/ui-locale';
import { getSharedUiCopy } from './shared-ui-copy.js';
/** Path and ownership fields are intentionally outside this boundary. */
-export interface ArtifactPreviewInput {
- name: string;
- kind: ArtifactKind;
- mimeType?: string;
- sizeBytes?: number;
-}
-
+export type ArtifactPreviewInput = ArtifactImagePreviewInput;
export type PreviewResolution =
- | {
- kind: 'image';
- reason: 'mime_match' | 'ext_fallback';
- }
- | {
- kind: 'unsupported';
- reason: 'kind_disallowed' | 'mime_disallowed' | 'no_mime_no_ext' | 'oversize' | 'read_failed';
- };
-
-/** Maximum decoded image payload allowed into renderer state. */
-export const IMAGE_PAYLOAD_MAX_BYTES = 2 * 1024 * 1024;
+ | ArtifactImagePreviewResolution
+ | { kind: 'unsupported'; reason: 'read_failed' };
/** Encoded-length cap, including base64 padding. */
-const IMAGE_PAYLOAD_MAX_BASE64_LENGTH = Math.ceil((IMAGE_PAYLOAD_MAX_BYTES * 4) / 3) + 2;
-
-/**
- * MIME allowlist shared by metadata and post-load validation. SVG is
- * intentionally absent.
- */
-const ALLOWED_IMAGE_MIMES: ReadonlySet<string> = new Set([
- 'image/png',
- 'image/jpeg',
- 'image/gif',
- 'image/webp',
- 'image/avif',
-]);
-
-/** Return a normalized allowlisted MIME for constructing an image data URL. */
-function normalizeAllowedImageMime(mimeType: string | undefined): string | null {
- if (typeof mimeType !== 'string') return null;
- const mime = mimeType.trim().toLowerCase();
- if (mime === '') return null;
- return ALLOWED_IMAGE_MIMES.has(mime) ? mime : null;
-}
+const IMAGE_PAYLOAD_MAX_BASE64_LENGTH =
+ Math.ceil((ARTIFACT_IMAGE_PREVIEW_MAX_BYTES * 4) / 3) + 2;
/** Post-load decision after payload size and sniffed MIME validation. */
export type ImagePostLoadOutcome =
@@ -80,7 +53,7 @@ function decideImagePostLoad(input: {
if (exceedsImagePayloadCap(input.base64)) {
return { kind: 'unsupported', reason: 'oversize' };
}
- const safeMime = normalizeAllowedImageMime(input.mimeType);
+ const safeMime = normalizeArtifactImagePreviewMime(input.mimeType);
if (!safeMime) {
return { kind: 'unsupported', reason: 'mime_disallowed' };
}
@@ -98,37 +71,8 @@ export function decideImageReadOutcome(readResult: ArtifactBinaryReadResult): Im
return decideImagePostLoad({ base64: readResult.base64, mimeType: readResult.mimeType });
}
-/** Safe extension fallback when MIME metadata is absent. */
-const ALLOWED_IMAGE_EXTS: ReadonlySet<string> = new Set([
- '.png',
- '.jpg',
- '.jpeg',
- '.gif',
- '.webp',
- '.avif',
-]);
-
export function resolvePreviewKind(input: ArtifactPreviewInput): PreviewResolution {
- if (input.kind !== 'image') {
- return { kind: 'unsupported', reason: 'kind_disallowed' };
- }
- // Reject by metadata before materializing base64.
- if (input.sizeBytes !== undefined && input.sizeBytes > IMAGE_PAYLOAD_MAX_BYTES) {
- return { kind: 'unsupported', reason: 'oversize' };
- }
- // A present MIME is authoritative; extension fallback cannot override it.
- if (input.mimeType) {
- const mime = input.mimeType.trim().toLowerCase();
- if (ALLOWED_IMAGE_MIMES.has(mime)) {
- return { kind: 'image', reason: 'mime_match' };
- }
- return { kind: 'unsupported', reason: 'mime_disallowed' };
- }
- const ext = lowercaseExt(input.name);
- if (ext && ALLOWED_IMAGE_EXTS.has(ext)) {
- return { kind: 'image', reason: 'ext_fallback' };
- }
- return { kind: 'unsupported', reason: 'no_mime_no_ext' };
+ return resolveArtifactImagePreview(input);
}
/** Enforce the post-load cap using encoded length without decoding. */
@@ -143,10 +87,3 @@ export function formatPreviewSize(sizeBytes: number | undefined, locale: UiLocal
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`;
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
}
-
-function lowercaseExt(name: string): string | null {
- if (typeof name !== 'string') return null;
- const idx = name.lastIndexOf('.');
- if (idx <= 0 || idx === name.length - 1) return null;
- return name.slice(idx).toLowerCase();
-}
diff --git a/packages/ui/src/attachment-image.tsx b/packages/ui/src/attachment-image.tsx
index ea5bdaa9a..19b88e9a6 100644
--- a/packages/ui/src/attachment-image.tsx
+++ b/packages/ui/src/attachment-image.tsx
@@ -36,7 +36,7 @@ export type ReadAttachmentBytes = (
type SessionAttachmentContextValue = {
sessionId: string;
- loadImage: (sessionId: string, artifactId: string) => Promise<ArtifactBinaryReadResult>;
+ loadImage: (sessionId: string, artifactId: string) => Promise<string | undefined>;
};
const SessionAttachmentContext = createContext<SessionAttachmentContextValue | undefined>(undefined);
@@ -51,16 +51,21 @@ export function SessionAttachmentProvider(props: {
() => {
const readBytes = props.readBytes;
if (!readBytes) return undefined;
- const pending = new Map<string, Promise<ArtifactBinaryReadResult>>();
+ const pending = new Map<string, Promise<string | undefined>>();
return {
sessionId: props.sessionId,
loadImage(sessionId: string, artifactId: string) {
const key = `${sessionId}\0${artifactId}`;
const existing = pending.get(key);
if (existing) return existing;
- const loaded = readBytes(sessionId, artifactId).catch(
- (): ArtifactBinaryReadResult => ({ ok: false, reason: 'read_failed' }),
- );
+ const loaded = readBytes(sessionId, artifactId)
+ .then((result) => {
+ const outcome = decideImageReadOutcome(result);
+ return outcome.kind === 'image'
+ ? `data:${outcome.safeMime};base64,${outcome.base64}`
+ : undefined;
+ })
+ .catch(() => undefined);
pending.set(key, loaded);
void loaded.finally(() => {
if (pending.get(key) === loaded) pending.delete(key);
@@ -82,7 +87,7 @@ export function SessionAttachmentProvider(props: {
export function useAttachmentImageSource(ref: {
artifactId: string;
sessionId?: string;
-} | undefined, safePreview = false): string | undefined {
+} | undefined): string | undefined {
const context = useContext(SessionAttachmentContext);
const artifactId = ref?.artifactId;
const sessionId = ref?.sessionId ?? context?.sessionId;
@@ -94,20 +99,13 @@ export function useAttachmentImageSource(ref: {
if (!artifactId || !sessionId || !loadImage) return;
let cancelled = false;
loadImage(sessionId, artifactId)
- .then((result) => {
- if (cancelled || !result.ok) return;
- const outcome = safePreview ? decideImageReadOutcome(result) : undefined;
- if (safePreview && outcome?.kind !== 'image') return;
- setSrc(
- outcome?.kind === 'image'
- ? `data:${outcome.safeMime};base64,${outcome.base64}`
- : `data:${result.mimeType};base64,${result.base64}`,
- );
+ .then((loaded) => {
+ if (!cancelled) setSrc(loaded);
})
return () => {
cancelled = true;
};
- }, [artifactId, loadImage, safePreview, sessionId]);
+ }, [artifactId, loadImage, sessionId]);
return src;
}
diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx
index 540dfcf86..53deba2af 100644
--- a/packages/ui/src/chat-turn.tsx
+++ b/packages/ui/src/chat-turn.tsx
@@ -72,6 +72,7 @@ import { AstryxLocaleProvider } from './astryx-i18n.js';
import { InlineReferenceText } from './inline-reference.js';
import { redactSecrets } from './redact.js';
import { useAttachmentImageSource } from './attachment-image.js';
+import { resolvePreviewKind } from './artifact-preview-registry.js';
export function LocalizedChatMessage({
accessibleLabel,
@@ -98,7 +99,13 @@ function legacySentSkillTokens(text: string) {
}
function AttachmentImage(props: { attachment: AttachmentRef }) {
- const ref = props.attachment.ref.kind === 'session_file'
+ const preview = resolvePreviewKind({
+ name: props.attachment.name,
+ kind: 'image',
+ mimeType: props.attachment.mimeType,
+ sizeBytes: props.attachment.bytes,
+ });
+ const ref = preview.kind === 'image' && props.attachment.ref.kind === 'session_file'
? {
sessionId: props.attachment.ref.sessionId,
artifactId: props.attachment.ref.relativePath,
@@ -111,7 +118,7 @@ function AttachmentImage(props: { attachment: AttachmentRef }) {
className="maka-user-attachment-thumbnail"
alt={props.attachment.name}
label={props.attachment.name}
- isLoading
+ isLoading={preview.kind === 'image'}
/>
);
}
diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx
index 50a5eedb5..1687ae12b 100644
--- a/packages/ui/src/markdown-body.tsx
+++ b/packages/ui/src/markdown-body.tsx
@@ -252,7 +252,6 @@ function MarkdownImage(props: { src: string; alt: string }) {
const attachment = parseAttachmentResourceRef(props.src);
const attachmentSrc = useAttachmentImageSource(
attachment ? { artifactId: attachment.artifactId } : undefined,
- true,
);
if (attachment) {
if (!attachmentSrc) return <span>[{props.alt}]</span>;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 03/06: fix: harden session attachment image loading
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/4093-markdown-attachment-images
in repository https://gitbox.apache.org/repos/asf/maka.git
commit f37399a97044f770184b5f3b3ade56cdf0720cab
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Aug 29 16:54:55 2026 +0800
fix: harden session attachment image loading
Rewrite canonical attachment resource references when conversations are copied so branched and companion transcripts keep rendering the copied Artifact. Route Quote Companion through the existing Workbar attachment service, reuse the renderer image allowlist and payload cap, and share repeated reads per Session provider.
Generated-by: Codex
---
apps/desktop/src/preload/bridge-contract.d.ts | 5 +-
apps/desktop/src/preload/preload.ts | 7 +-
.../desktop/src/renderer/features/workbar/ports.ts | 1 +
.../src/renderer/features/workbar/testing.ts | 1 +
.../tools/side-chat/quote-companion-panel.tsx | 1 +
.../platform/desktop/create-workbar-services.ts | 2 +
.../src/__tests__/conversation-copy.test.ts | 36 ++++++++
packages/runtime/src/conversation-copy.ts | 22 +++++
.../ui/src/__tests__/attachment-image.test.tsx | 98 +++++++++++++++-------
packages/ui/src/attachment-image.tsx | 47 ++++++++---
packages/ui/stories/attachment.stories.tsx | 4 +-
11 files changed, 170 insertions(+), 54 deletions(-)
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 85c431907..a639c67fa 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -1344,10 +1344,7 @@ export interface MakaBridge {
| { ok: true; base64: string; mimeType: string }
| { ok: false; reason: string }
>;
- readBytes(sessionId: string, relativePath: string): Promise<
- | { ok: true; base64: string; mimeType: string }
- | { ok: false; reason: string }
- >;
+ readBytes(sessionId: string, artifactId: string): Promise<ArtifactBinaryReadResult>;
};
search: {
thread(
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index c21d120bf..9f59cfad0 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -2593,11 +2593,8 @@ const makaBridge = {
> {
return ipcRenderer.invoke('attachments:previewApproval', approvalId);
},
- readBytes(sessionId: string, relativePath: string): Promise<
- | { ok: true; base64: string; mimeType: string }
- | { ok: false; reason: string }
- > {
- return invokeSessionRuntimeHost('attachments:readBytes', sessionId, relativePath);
+ readBytes(sessionId: string, artifactId: string): Promise<ArtifactBinaryReadResult> {
+ return invokeSessionRuntimeHost('attachments:readBytes', sessionId, artifactId);
},
},
search: {
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index 4c1981dbf..8efcf71ac 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -177,6 +177,7 @@ export interface WorkbarInspectorService {
}
export interface WorkbarAttachmentsService {
+ readBytes(sessionId: string, artifactId: string): Promise<ArtifactBinaryReadResult>;
pickFiles(): Promise<
| {
ok: true;
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index 532076cbd..9ecf0928f 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -113,6 +113,7 @@ export function createFakeWorkbarServices(
subscribeUsageChanges: noopSubscription,
},
attachments: {
+ readBytes: async () => ({ ok: false, reason: 'not_found' }),
pickFiles: async () => ({ ok: false, reason: 'cancelled' }),
previewApproval: async () => ({ ok: false, reason: 'not configured' }),
},
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
index 52f4ad3d5..af1bac05d 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
@@ -303,6 +303,7 @@ export function QuoteCompanionPanel(props: {
liveTurn={companion.liveTurn}
runningStatus={companion.processing}
activeSession={companion.companionSession}
+ onReadAttachmentBytes={attachments.readBytes}
deriveTurnPresentation={deriveTurnPresentation}
onTurnFooterAction={(turnId, actionId) => {
if (actionId === 'regenerate') {
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
index 2863994d6..2a741425c 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -104,6 +104,8 @@ export function createDesktopWorkbarServices(
bridge.inspector.subscribeUsageChanges(sessionId, handler),
},
attachments: {
+ readBytes: (sessionId, artifactId) =>
+ bridge.attachments.readBytes(sessionId, artifactId),
pickFiles: () => bridge.attachments.pickFiles(),
previewApproval: (approvalId) =>
bridge.attachments.previewApproval(approvalId),
diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts
index cee3ac1fe..e0662f09d 100644
--- a/packages/runtime/src/__tests__/conversation-copy.test.ts
+++ b/packages/runtime/src/__tests__/conversation-copy.test.ts
@@ -860,6 +860,42 @@ test('conversation copy rewrites owned references without changing opaque tool p
);
});
+test('conversation copy rewrites assistant Markdown attachment refs to copied Artifact ids', () => {
+ const message: StoredMessage = {
+ type: 'assistant',
+ id: 'assistant-1',
+ turnId: 'turn-1',
+ ts: 1,
+ text: [
+ '',
+ '`maka://runtime/attachments/artifact-source`',
+ '',
+ ].join('\n'),
+ modelId: 'model',
+ };
+
+ const rewritten = rewriteConversationCopyMessage(message, {
+ mode: 'exact',
+ linkedChildren: { mode: 'reject' },
+ sourceSessionId: 'session-source',
+ targetSessionId: 'session-target',
+ artifactIds: new Map([['artifact-source', 'artifact-target']]),
+ relativePaths: new Map(),
+ runIds: new Map(),
+ runtimeEventIds: new Map(),
+ providerTraceIds: new Map(),
+ });
+
+ assert.equal(
+ rewritten.type === 'assistant' ? rewritten.text : undefined,
+ [
+ '',
+ '`maka://runtime/attachments/artifact-target`',
+ '',
+ ].join('\n'),
+ );
+});
+
test('conversation copy rejects continuation authority selected through the child-run closure', async () => {
const parent = agentRunHeader({ runId: 'run-parent', turnId: 'turn-parent' });
const child = agentRunHeader({
diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts
index f688f47ab..1d24d3ddd 100644
--- a/packages/runtime/src/conversation-copy.ts
+++ b/packages/runtime/src/conversation-copy.ts
@@ -26,6 +26,7 @@ import type {
import type { RuntimeEvent } from '@maka/core/runtime-event';
import type { RuntimeEventStore } from '@maka/core/runtime-event-store';
import type { StorageRef, ToolResultContent } from '@maka/core/events';
+import { parseAttachmentResourceRef } from '@maka/core/attachments';
import { markPersisted } from '@maka/core/persisted-value';
import type { StoredMessage } from '@maka/core/session';
import { decodePersistedToolResultContent } from '@maka/core/tool-result-record-schema';
@@ -196,6 +197,12 @@ export function rewriteConversationCopyMessage(
message: StoredMessage,
references: ConversationCopyMessageReferenceMap,
): StoredMessage {
+ if (message.type === 'assistant' && references.mode === 'exact') {
+ return {
+ ...message,
+ text: rewriteAttachmentResourceRefs(message.text, references.artifactIds),
+ };
+ }
if (message.type === 'user' && message.attachments) {
return {
...message,
@@ -224,6 +231,21 @@ export function rewriteConversationCopyMessage(
return message;
}
+const ATTACHMENT_RESOURCE_REF_PATTERN =
+ /maka:\/\/runtime\/attachments\/[A-Za-z0-9_-]{1,128}(?![A-Za-z0-9_-])/g;
+
+function rewriteAttachmentResourceRefs(
+ text: string,
+ artifactIds: ReadonlyMap<string, string>,
+): string {
+ return text.replace(ATTACHMENT_RESOURCE_REF_PATTERN, (resourceRef) => {
+ const parsed = parseAttachmentResourceRef(resourceRef);
+ if (!parsed) return resourceRef;
+ const artifactId = artifactIds.get(parsed.artifactId);
+ return artifactId ? `maka://runtime/attachments/${artifactId}` : resourceRef;
+ });
+}
+
export async function prepareConversationRuntimeLedgerCopy(input: {
readonly sourceSessionId: string;
readonly sourceEvents: readonly RuntimeEvent[];
diff --git a/packages/ui/src/__tests__/attachment-image.test.tsx b/packages/ui/src/__tests__/attachment-image.test.tsx
index 3d66ac365..ce9c23afa 100644
--- a/packages/ui/src/__tests__/attachment-image.test.tsx
+++ b/packages/ui/src/__tests__/attachment-image.test.tsx
@@ -23,7 +23,10 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import { TurnView } from '../chat-turn.js';
-import { SessionAttachmentProvider } from '../attachment-image.js';
+import {
+ SessionAttachmentProvider,
+ type ReadAttachmentBytes,
+} from '../attachment-image.js';
import { LocaleProvider } from '../locale-context.js';
import { MarkdownBody } from '../markdown-body.js';
import type { TurnViewModel } from '../materialize.js';
@@ -65,6 +68,18 @@ function domRoot() {
return { container, root };
}
+async function renderAttachmentMarkdown(text: string, readBytes: ReadAttachmentBytes) {
+ const { container, root } = domRoot();
+ await act(async () => {
+ root.render(
+ <SessionAttachmentProvider sessionId="session-1" readBytes={readBytes}>
+ <MarkdownBody text={text} />
+ </SessionAttachmentProvider>,
+ );
+ });
+ return container;
+}
+
const TURN_WITH_IMAGE: TurnViewModel = {
turnId: 'turn-1',
status: 'completed',
@@ -113,25 +128,14 @@ test('loads a user attachment thumbnail through the injected session reader', as
});
test('renders a session attachment referenced by assistant Markdown', async () => {
- const { container, root } = domRoot();
let readRef: { sessionId: string; artifactId: string } | undefined;
- await act(async () => {
- root.render(
- <SessionAttachmentProvider
- sessionId="session-1"
- readBytes={async (sessionId, artifactId) => {
- readRef = { sessionId, artifactId };
- return {
- ok: true,
- base64: 'aW1n',
- mimeType: 'image/png',
- };
- }}
- >
- <MarkdownBody text="" />
- </SessionAttachmentProvider>,
- );
- });
+ const container = await renderAttachmentMarkdown(
+ '',
+ async (sessionId, artifactId) => {
+ readRef = { sessionId, artifactId };
+ return { ok: true, base64: 'aW1n', mimeType: 'image/png' };
+ },
+ );
const image = container.querySelector('img[alt="preview"]');
assert.ok(image);
@@ -140,22 +144,56 @@ test('renders a session attachment referenced by assistant Markdown', async () =
});
test('keeps an unknown assistant attachment as a named placeholder', async () => {
- const { container, root } = domRoot();
- await act(async () => {
- root.render(
- <SessionAttachmentProvider
- sessionId="session-1"
- readBytes={async () => ({ ok: false })}
- >
- <MarkdownBody text="" />
- </SessionAttachmentProvider>,
- );
- });
+ const container = await renderAttachmentMarkdown(
+ '',
+ async () => ({ ok: false, reason: 'not_found' }),
+ );
assert.equal(container.querySelector('img'), null);
assert.match(container.textContent, /\[missing\]/);
});
+test('keeps a non-image assistant attachment as a named placeholder', async () => {
+ const container = await renderAttachmentMarkdown(
+ '',
+ async () => ({ ok: true, base64: 'cGRm', mimeType: 'application/pdf' }),
+ );
+
+ assert.equal(container.querySelector('img'), null);
+ assert.match(container.textContent, /\[document\]/);
+});
+
+test('keeps an oversized assistant image out of renderer state', async () => {
+ const container = await renderAttachmentMarkdown(
+ '',
+ async () => ({
+ ok: true,
+ base64: 'a'.repeat(3 * 1024 * 1024),
+ mimeType: 'image/png',
+ }),
+ );
+
+ assert.equal(container.querySelector('img'), null);
+ assert.match(container.textContent, /\[large\]/);
+});
+
+test('shares one attachment read across repeated Markdown image refs', async () => {
+ let reads = 0;
+ const container = await renderAttachmentMarkdown(
+ [
+ '',
+ '',
+ ].join('\n\n'),
+ async () => {
+ reads += 1;
+ return { ok: true, base64: 'aW1n', mimeType: 'image/png' };
+ },
+ );
+
+ assert.equal(container.querySelectorAll('img').length, 2);
+ assert.equal(reads, 1);
+});
+
test('renders a restored attachment through the streaming Markdown path', async () => {
const { container, root } = domRoot();
const markdown = '';
diff --git a/packages/ui/src/attachment-image.tsx b/packages/ui/src/attachment-image.tsx
index 63f94316d..65242316b 100644
--- a/packages/ui/src/attachment-image.tsx
+++ b/packages/ui/src/attachment-image.tsx
@@ -25,16 +25,18 @@ import {
useState,
type ReactNode,
} from 'react';
+import type { ArtifactBinaryReadResult } from '@maka/core/artifacts';
+import { decideImageReadOutcome } from './artifact-preview-registry.js';
/** Host capability for reading bytes from the Runtime Host attachment authority. */
export type ReadAttachmentBytes = (
sessionId: string,
artifactId: string,
-) => Promise<{ ok: true; base64: string; mimeType: string } | { ok: false }>;
+) => Promise<ArtifactBinaryReadResult>;
type SessionAttachmentContextValue = {
sessionId: string;
- readBytes: ReadAttachmentBytes;
+ loadImage: (sessionId: string, artifactId: string) => Promise<string | undefined>;
};
const SessionAttachmentContext = createContext<SessionAttachmentContextValue | undefined>(undefined);
@@ -46,9 +48,30 @@ export function SessionAttachmentProvider(props: {
children: ReactNode;
}) {
const value = useMemo(
- () => props.readBytes
- ? { sessionId: props.sessionId, readBytes: props.readBytes }
- : undefined,
+ () => {
+ const readBytes = props.readBytes;
+ if (!readBytes) return undefined;
+ const pending = new Map<string, Promise<string | undefined>>();
+ return {
+ sessionId: props.sessionId,
+ loadImage(sessionId: string, artifactId: string) {
+ const key = `${sessionId}\0${artifactId}`;
+ const existing = pending.get(key);
+ if (existing) return existing;
+ const loaded = readBytes(sessionId, artifactId)
+ .then((result) => {
+ if (!result.ok) return undefined;
+ const outcome = decideImageReadOutcome(result);
+ return outcome.kind === 'image'
+ ? `data:${outcome.safeMime};base64,${outcome.base64}`
+ : undefined;
+ })
+ .catch(() => undefined);
+ pending.set(key, loaded);
+ return loaded;
+ },
+ };
+ },
[props.readBytes, props.sessionId],
);
return (
@@ -66,23 +89,21 @@ export function useAttachmentImageSource(ref: {
const context = useContext(SessionAttachmentContext);
const artifactId = ref?.artifactId;
const sessionId = ref?.sessionId ?? context?.sessionId;
- const readBytes = context?.readBytes;
+ const loadImage = context?.loadImage;
const [src, setSrc] = useState<string | undefined>(undefined);
useEffect(() => {
setSrc(undefined);
- if (!artifactId || !sessionId || !readBytes) return;
+ if (!artifactId || !sessionId || !loadImage) return;
let cancelled = false;
- readBytes(sessionId, artifactId)
- .then((result) => {
- if (cancelled || !result.ok) return;
- setSrc(`data:${result.mimeType};base64,${result.base64}`);
+ loadImage(sessionId, artifactId)
+ .then((loaded) => {
+ if (!cancelled) setSrc(loaded);
})
- .catch(() => {});
return () => {
cancelled = true;
};
- }, [artifactId, readBytes, sessionId]);
+ }, [artifactId, loadImage, sessionId]);
return src;
}
diff --git a/packages/ui/stories/attachment.stories.tsx b/packages/ui/stories/attachment.stories.tsx
index f0cd66814..f6c687a7e 100644
--- a/packages/ui/stories/attachment.stories.tsx
+++ b/packages/ui/stories/attachment.stories.tsx
@@ -36,9 +36,9 @@ const BLUE_PNG =
// @maka/ui is host-agnostic: image thumbnails read bytes through the injected
// `onReadAttachmentBytes` prop, not a host global. The story supplies a fake
// reader that echoes the two solid-color PNGs above.
-const mockReadBytes = async (_sessionId: string, relativePath: string) => ({
+const mockReadBytes = async (_sessionId: string, artifactId: string) => ({
ok: true as const,
- base64: relativePath.includes('metrics') ? BLUE_PNG : RED_PNG,
+ base64: artifactId.includes('metrics') ? BLUE_PNG : RED_PNG,
mimeType: 'image/png',
});
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 04/06: fix(runtime): preserve attachment refs in conversation copies
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/4093-markdown-attachment-images
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 52eaba037744558883a52e200314eff47e48a9bf
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Aug 29 17:52:21 2026 +0800
fix(runtime): preserve attachment refs in conversation copies
Generated-by: Codex
---
.../src/__tests__/conversation-copy.test.ts | 85 ++++++++++++----------
.../src/__tests__/model-history-attachment.test.ts | 61 ----------------
.../src/__tests__/runtime-event-read-model.test.ts | 33 +++++++++
packages/runtime/src/conversation-copy.ts | 37 ++++++----
4 files changed, 103 insertions(+), 113 deletions(-)
diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts
index e0662f09d..87223750e 100644
--- a/packages/runtime/src/__tests__/conversation-copy.test.ts
+++ b/packages/runtime/src/__tests__/conversation-copy.test.ts
@@ -695,6 +695,33 @@ test('conversation copy rewrites owned references without changing opaque tool p
sessionId: 'session-target',
relativePath: 'session-target/artifact-target-file.txt',
});
+ const userMessage = messages[0];
+ assert.equal(userMessage?.type, 'user');
+ if (userMessage?.type !== 'user') return;
+ const canonicalAttachment = userMessage.attachments?.[0];
+ assert.ok(canonicalAttachment);
+ const canonical = rewriteConversationCopyMessage(
+ {
+ ...userMessage,
+ attachments: [
+ {
+ ...canonicalAttachment,
+ ref: {
+ kind: 'session_file',
+ sessionId: 'session-source',
+ relativePath: 'artifact-source',
+ },
+ },
+ ],
+ },
+ references,
+ );
+ assert.equal(
+ canonical.type === 'user' && canonical.attachments?.[0]?.ref.kind === 'session_file'
+ ? canonical.attachments[0].ref.relativePath
+ : undefined,
+ 'artifact-target',
+ );
assert.deepEqual(
rewritten[1]?.type === 'tool_call' ? rewritten[1].args : undefined,
messages[1]?.type === 'tool_call' ? messages[1].args : undefined,
@@ -860,42 +887,6 @@ test('conversation copy rewrites owned references without changing opaque tool p
);
});
-test('conversation copy rewrites assistant Markdown attachment refs to copied Artifact ids', () => {
- const message: StoredMessage = {
- type: 'assistant',
- id: 'assistant-1',
- turnId: 'turn-1',
- ts: 1,
- text: [
- '',
- '`maka://runtime/attachments/artifact-source`',
- '',
- ].join('\n'),
- modelId: 'model',
- };
-
- const rewritten = rewriteConversationCopyMessage(message, {
- mode: 'exact',
- linkedChildren: { mode: 'reject' },
- sourceSessionId: 'session-source',
- targetSessionId: 'session-target',
- artifactIds: new Map([['artifact-source', 'artifact-target']]),
- relativePaths: new Map(),
- runIds: new Map(),
- runtimeEventIds: new Map(),
- providerTraceIds: new Map(),
- });
-
- assert.equal(
- rewritten.type === 'assistant' ? rewritten.text : undefined,
- [
- '',
- '`maka://runtime/attachments/artifact-target`',
- '',
- ].join('\n'),
- );
-});
-
test('conversation copy rejects continuation authority selected through the child-run closure', async () => {
const parent = agentRunHeader({ runId: 'run-parent', turnId: 'turn-parent' });
const child = agentRunHeader({
@@ -1684,12 +1675,20 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi
completedAt: 3,
};
await runStore.createRun(sourceRun);
+ const sourceAttachmentText = [
+ '',
+ 'maka://runtime/attachments/artifact-source?session=other',
+ ].join('\n');
+ const targetAttachmentText = [
+ '',
+ 'maka://runtime/attachments/artifact-source?session=other',
+ ].join('\n');
const sourceEvents: RuntimeEvent[] = [
runtimeEvent({
id: 'event-user',
- role: 'user',
- author: 'user',
- content: { kind: 'text', text: 'hello' },
+ role: 'model',
+ author: 'agent',
+ content: { kind: 'text', text: sourceAttachmentText },
refs: { artifactId: 'artifact-source' },
}),
runtimeEvent({
@@ -2013,6 +2012,14 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi
),
);
assert.equal(targetEvents[0]?.refs?.artifactId, 'artifact-target');
+ assert.equal(
+ targetEvents[0]?.content?.kind === 'text' ? targetEvents[0].content.text : undefined,
+ targetAttachmentText,
+ );
+ assert.equal(
+ copied.copiedMessages.find((message) => message.type === 'assistant')?.text,
+ targetAttachmentText,
+ );
assert.equal(targetEvents[1]?.refs?.sourceInvocationId, 'invocation-target');
assert.deepEqual(
targetEvents[1]?.content?.kind === 'function_call' ? targetEvents[1].content.args : undefined,
diff --git a/packages/runtime/src/__tests__/model-history-attachment.test.ts b/packages/runtime/src/__tests__/model-history-attachment.test.ts
deleted file mode 100644
index 069cf21f2..000000000
--- a/packages/runtime/src/__tests__/model-history-attachment.test.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * 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 type { RuntimeEvent } from '@maka/core/runtime-event';
-import { buildRuntimeEventModelReplayPlan } from '../model-history.js';
-
-test('tells the model that a session image ref is a Markdown image source', () => {
- const event: RuntimeEvent = {
- id: 'event-1',
- invocationId: 'invocation-1',
- runId: 'run-1',
- sessionId: 'session-1',
- turnId: 'turn-1',
- ts: 1,
- partial: false,
- role: 'user',
- author: 'user',
- content: {
- kind: 'text',
- text: 'show this',
- attachments: [
- {
- kind: 'image',
- name: 'preview.png',
- mimeType: 'image/png',
- bytes: 3,
- ref: {
- kind: 'session_file',
- sessionId: 'session-1',
- relativePath: 'attachment-123',
- },
- },
- ],
- },
- };
-
- const item = buildRuntimeEventModelReplayPlan([event]).items[0];
- assert.equal(item?.kind, 'text');
- assert.match(
- item.content,
- /Markdown image source: "maka:\/\/runtime\/attachments\/attachment-123"/,
- );
-});
diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts
index b172e83b1..b0dd3d815 100644
--- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts
+++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts
@@ -258,6 +258,39 @@ function equivalentLegacyMessages(): StoredMessage[] {
}
describe('projectRuntimeEventsToStoredMessages', () => {
+ test('exposes a session image ref as a Markdown image source to the model', () => {
+ const replay = buildRuntimeEventModelReplayPlan([
+ ev({
+ role: 'user',
+ author: 'user',
+ content: {
+ kind: 'text',
+ text: 'show this',
+ attachments: [
+ {
+ kind: 'image',
+ name: 'preview.png',
+ mimeType: 'image/png',
+ bytes: 3,
+ ref: {
+ kind: 'session_file',
+ sessionId,
+ relativePath: 'attachment-123',
+ },
+ },
+ ],
+ },
+ }),
+ ]);
+
+ const item = replay.items[0];
+ assert.equal(item?.kind, 'text');
+ assert.match(
+ item?.kind === 'text' ? item.content : '',
+ /Markdown image source: "maka:\/\/runtime\/attachments\/attachment-123"/,
+ );
+ });
+
test('projects user displayText from RuntimeEvent text content', () => {
const typed = '/skill:alpha 帮我整理';
const envelope = 'The user explicitly invoked…\n\n<user-message>\n帮我整理\n</user-message>';
diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts
index 1d24d3ddd..5e1722c44 100644
--- a/packages/runtime/src/conversation-copy.ts
+++ b/packages/runtime/src/conversation-copy.ts
@@ -231,18 +231,14 @@ export function rewriteConversationCopyMessage(
return message;
}
-const ATTACHMENT_RESOURCE_REF_PATTERN =
- /maka:\/\/runtime\/attachments\/[A-Za-z0-9_-]{1,128}(?![A-Za-z0-9_-])/g;
-
function rewriteAttachmentResourceRefs(
text: string,
artifactIds: ReadonlyMap<string, string>,
): string {
- return text.replace(ATTACHMENT_RESOURCE_REF_PATTERN, (resourceRef) => {
- const parsed = parseAttachmentResourceRef(resourceRef);
- if (!parsed) return resourceRef;
- const artifactId = artifactIds.get(parsed.artifactId);
- return artifactId ? `maka://runtime/attachments/${artifactId}` : resourceRef;
+ return text.replace(/maka:\/\/runtime\/attachments\/[^\s)\]}>`'",;:!]+/g, (candidate) => {
+ const parsed = parseAttachmentResourceRef(candidate);
+ const artifactId = parsed ? artifactIds.get(parsed.artifactId) : undefined;
+ return artifactId ? `maka://runtime/attachments/${artifactId}` : candidate;
});
}
@@ -1093,13 +1089,20 @@ function rewriteRuntimeEventReferences(
references: ConversationCopyReferenceMap,
): RuntimeEvent {
const content =
- event.content?.kind === 'text' && event.content.attachments
+ event.content?.kind === 'text'
? {
...event.content,
- attachments: event.content.attachments.map((attachment) => ({
- ...attachment,
- ref: rewriteStorageRef(attachment.ref, references),
- })),
+ ...(references.mode === 'exact'
+ ? { text: rewriteAttachmentResourceRefs(event.content.text, references.artifactIds) }
+ : {}),
+ ...(event.content.attachments
+ ? {
+ attachments: event.content.attachments.map((attachment) => ({
+ ...attachment,
+ ref: rewriteStorageRef(attachment.ref, references),
+ })),
+ }
+ : {}),
}
: event.content?.kind === 'function_response'
? {
@@ -1509,6 +1512,14 @@ function rewriteStorageRef(
): StorageRef {
if (ref.kind !== 'session_file' || ref.sessionId !== references.sourceSessionId) return ref;
if (references.mode === 'preserve_external') return ref;
+ const artifactId = references.artifactIds.get(ref.relativePath);
+ if (artifactId) {
+ return {
+ ...ref,
+ sessionId: references.targetSessionId,
+ relativePath: artifactId,
+ };
+ }
const relativePath = references.relativePaths.get(ref.relativePath);
if (!relativePath) {
throw new Error(`Conversation copy is missing Session file ${ref.relativePath}`);
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org