fix(FN-7143): support task image artifacts
Fusion-Task-Id: FN-7143 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7143-image-artifact-task-details.md
Normal file
7
.changeset/fn-7143-image-artifact-task-details.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Allow task image artifacts to be created from agent tools and viewed in task details.
|
||||
category: fix
|
||||
dev: Adds `dataBase64` support to `fn_artifact_register` and task-detail image preview expansion.
|
||||
@@ -54,7 +54,7 @@ These tools are **not** part of the user-invokable extension surface. They are i
|
||||
| `fn_send_message` | executor, step-session, heartbeat | Send inbox messages to agents/users | `to_id` (string), `content` (string), `type?` (`agent-to-agent` \| `agent-to-user`), `reply_to_message_id?` (string) |
|
||||
| `fn_read_messages` | executor, step-session, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) |
|
||||
| `fn_post_room_message` | heartbeat | Post a message to a chat room the agent is a member of | `roomId` (string), `content` (string), `replyToMessageId?` (string), `mentions?` (string[]) |
|
||||
| `fn_artifact_register` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it | `type` (string), `title` (string), `description?` (string), `mimeType?` (string), `uri?` (string), `content?` (string), `taskId?` (string); chat/planning also require `task_id` (string) |
|
||||
| `fn_artifact_register` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it; image artifacts may provide `dataBase64` bytes for registry-managed media storage | `type` (string), `title` (string), `description?` (string), `mimeType?` (string), `uri?` (string), `content?` (string), `dataBase64?` (base64 string), `taskId?` (string); chat/planning also require `task_id` (string) |
|
||||
| `fn_artifact_list` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | List registered artifacts across agents and tasks with filters for type, authorId, taskId, search, limit, and offset | `type?` (string), `authorId?` (string), `taskId?` (string), `search?` (string), `limit?` (number), `offset?` (number); chat/planning also require `task_id` (string) |
|
||||
| `fn_artifact_view` | triage, executor, heartbeat | View a registered artifact by id, including metadata and inline content or the uri/path reference for media artifacts | `id` (string) |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History } from "lucide-react";
|
||||
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History, X } from "lucide-react";
|
||||
import "./DocumentsView.css";
|
||||
import "./TaskDocumentsTab.css";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@@ -56,9 +56,10 @@ function formatFileSize(bytes: number): string {
|
||||
interface TaskArtifactCardProps {
|
||||
artifact: ArtifactWithTask;
|
||||
projectId?: string;
|
||||
onExpandImage: (artifact: ArtifactWithTask) => void;
|
||||
}
|
||||
|
||||
function TaskArtifactCard({ artifact, projectId }: TaskArtifactCardProps) {
|
||||
function TaskArtifactCard({ artifact, projectId, onExpandImage }: TaskArtifactCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
|
||||
const typeLabel = getArtifactTypeLabel(t, artifact.type);
|
||||
@@ -67,9 +68,21 @@ function TaskArtifactCard({ artifact, projectId }: TaskArtifactCardProps) {
|
||||
|
||||
return (
|
||||
<article className="document-card documents-artifact-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
||||
<div className="documents-artifact-preview">
|
||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
|
||||
</div>
|
||||
{artifact.type === "image" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="documents-artifact-preview documents-artifact-preview--expandable"
|
||||
onClick={() => onExpandImage(artifact)}
|
||||
aria-label={t("documents.expandImageArtifact", "Expand image artifact {{title}}", { title })}
|
||||
>
|
||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
|
||||
<span className="documents-artifact-expand-hint">{t("documents.expandArtifactHint", "Click to expand")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="documents-artifact-preview">
|
||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
|
||||
</div>
|
||||
)}
|
||||
<div className="documents-artifact-body">
|
||||
<div className="documents-artifact-header">
|
||||
<span className="documents-artifact-type-badge">{typeLabel}</span>
|
||||
@@ -110,6 +123,13 @@ export function TaskDocumentsTab({
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState(false);
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-06-29-00:00:
|
||||
* Task detail image artifacts must be viewable in-place from the task modal. Keep the expand target image-only so document, audio, video, and generic cards retain their current non-lightbox behavior without empty controls.
|
||||
*/
|
||||
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactWithTask | null>(null);
|
||||
const lightboxCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const lightboxReturnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId });
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
@@ -266,6 +286,46 @@ export function TaskDocumentsTab({
|
||||
setEditContent("");
|
||||
}
|
||||
|
||||
const handleExpandArtifactImage = useCallback((artifact: ArtifactWithTask) => {
|
||||
lightboxReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setLightboxArtifact(artifact);
|
||||
}, []);
|
||||
|
||||
const handleCloseLightbox = useCallback(() => {
|
||||
setLightboxArtifact(null);
|
||||
lightboxReturnFocusRef.current?.focus();
|
||||
lightboxReturnFocusRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightboxArtifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
lightboxCloseRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleCloseLightbox();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCloseLightbox, lightboxArtifact]);
|
||||
|
||||
const handleLightboxOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
handleCloseLightbox();
|
||||
}
|
||||
}, [handleCloseLightbox]);
|
||||
|
||||
if (loading || artifactsLoading) {
|
||||
return (
|
||||
<div className="detail-section">
|
||||
@@ -299,7 +359,7 @@ export function TaskDocumentsTab({
|
||||
</div>
|
||||
<div className="documents-artifact-gallery documents-artifact-gallery--mobile task-artifacts-gallery">
|
||||
{artifacts.map((artifact) => (
|
||||
<TaskArtifactCard key={artifact.id} artifact={artifact} projectId={projectId} />
|
||||
<TaskArtifactCard key={artifact.id} artifact={artifact} projectId={projectId} onExpandImage={handleExpandArtifactImage} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -549,6 +609,32 @@ export function TaskDocumentsTab({
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{lightboxArtifact && (
|
||||
<div
|
||||
className="modal-overlay open documents-artifact-lightbox-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("documents.lightboxLabel", "Artifact media preview")}
|
||||
onClick={handleLightboxOverlayClick}
|
||||
>
|
||||
<div className="documents-artifact-lightbox" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="documents-artifact-lightbox-header">
|
||||
<h3 className="documents-artifact-lightbox-title">{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}</h3>
|
||||
<button ref={lightboxCloseRef} className="modal-close" onClick={handleCloseLightbox} aria-label={t("documents.closeLightbox", "Close artifact preview")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="documents-artifact-lightbox-media-frame">
|
||||
<img
|
||||
className="documents-artifact-lightbox-media"
|
||||
src={artifactMediaUrl(lightboxArtifact.id, projectId)}
|
||||
alt={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,8 @@ describe("TaskDocumentsTab", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
|
||||
expect(screen.getByRole("button", { name: "Expand image artifact Image artifact" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Expand image artifact Video artifact/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
|
||||
expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO");
|
||||
expect(screen.getByTestId("artifact-document-preview")).toHaveTextContent("Inline document preview");
|
||||
@@ -202,6 +204,54 @@ describe("TaskDocumentsTab", () => {
|
||||
expect(mockArtifactMediaUrl).toHaveBeenCalledWith("artifact-image", "project-1");
|
||||
});
|
||||
|
||||
it("opens image artifacts in a task-detail lightbox and restores focus on close", async () => {
|
||||
mockFetchTaskDocuments.mockResolvedValue([]);
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: mockArtifacts,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
||||
|
||||
const expandButton = await screen.findByRole("button", { name: "Expand image artifact Image artifact" });
|
||||
expandButton.focus();
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("img", { name: "Image artifact" })[1]).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(expandButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it("closes the image artifact lightbox with Escape", async () => {
|
||||
mockFetchTaskDocuments.mockResolvedValue([]);
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: mockArtifacts,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Expand image artifact Image artifact" }));
|
||||
expect(screen.getByRole("dialog", { name: "Artifact media preview" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces artifact fetch errors", async () => {
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: [],
|
||||
|
||||
@@ -84,6 +84,44 @@ describe("artifacts route integration", () => {
|
||||
expect(Buffer.from(res.body as string, "utf8")).toEqual(imageBytes);
|
||||
});
|
||||
|
||||
it("a global image artifact still streams from the managed global artifacts directory", async () => {
|
||||
const imageBytes = Buffer.from("PNG-FN-7143-global-image-bytes", "utf8");
|
||||
const artifact = await store.registerArtifact({
|
||||
type: "image",
|
||||
title: "Global screenshot",
|
||||
mimeType: "image/png",
|
||||
data: imageBytes,
|
||||
authorId: "agent-7143",
|
||||
authorType: "agent",
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "GET", `/api/artifacts/${artifact.id}/media`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("image/png");
|
||||
expect(Buffer.from(res.body as string, "utf8")).toEqual(imageBytes);
|
||||
});
|
||||
|
||||
it("a URI-only image artifact whose file is missing still returns 404", async () => {
|
||||
const task = await store.createTask({
|
||||
title: "Missing screenshot",
|
||||
description: "Preserve existing missing media semantics",
|
||||
});
|
||||
const artifact = await store.registerArtifact({
|
||||
type: "image",
|
||||
title: "Missing screenshot",
|
||||
mimeType: "image/png",
|
||||
uri: "artifacts/missing.png",
|
||||
authorId: "agent-7143",
|
||||
authorType: "agent",
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "GET", `/api/artifacts/${artifact.id}/media`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("a task with no artifacts lists as empty", async () => {
|
||||
const task = await store.createTask({
|
||||
title: "Render screenshot",
|
||||
|
||||
@@ -89,6 +89,72 @@ describe("artifact register tool", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("decodes base64 image bytes before calling store.registerArtifact", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
const imageBytes = Buffer.from("small-png-bytes");
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({
|
||||
id: "art-image",
|
||||
type: "image",
|
||||
title: "Screenshot",
|
||||
mimeType: "image/png",
|
||||
uri: "artifacts/screenshot.png",
|
||||
content: undefined,
|
||||
}));
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
|
||||
const result = await runTool(tool, "call-register-image", {
|
||||
type: "image",
|
||||
title: "Screenshot",
|
||||
mimeType: "image/png",
|
||||
dataBase64: imageBytes.toString("base64"),
|
||||
taskId: TASK_ID,
|
||||
});
|
||||
|
||||
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "image",
|
||||
title: "Screenshot",
|
||||
mimeType: "image/png",
|
||||
taskId: TASK_ID,
|
||||
content: undefined,
|
||||
uri: undefined,
|
||||
data: imageBytes,
|
||||
}));
|
||||
expect(getText(result)).toContain("Registered artifact");
|
||||
expect(getText(result)).not.toContain("ERROR:");
|
||||
});
|
||||
|
||||
it("returns an ERROR response without registering malformed base64 image payloads", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
|
||||
const result = await runTool(tool, "call-register-invalid-image", {
|
||||
type: "image",
|
||||
title: "Broken screenshot",
|
||||
mimeType: "image/png",
|
||||
dataBase64: "not valid base64!",
|
||||
taskId: TASK_ID,
|
||||
});
|
||||
|
||||
expect(registerArtifact).not.toHaveBeenCalled();
|
||||
expect(getText(result)).toContain("ERROR: Failed to register artifact");
|
||||
expect(getText(result)).toContain("dataBase64 must be valid base64");
|
||||
});
|
||||
|
||||
it("requires an image MIME type for base64 image registration", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
|
||||
const result = await runTool(tool, "call-register-missing-mime", {
|
||||
type: "image",
|
||||
title: "No MIME screenshot",
|
||||
dataBase64: Buffer.from("bytes").toString("base64"),
|
||||
taskId: TASK_ID,
|
||||
});
|
||||
|
||||
expect(registerArtifact).not.toHaveBeenCalled();
|
||||
expect(getText(result)).toContain("image artifacts registered with dataBase64 require an image/* mimeType");
|
||||
});
|
||||
|
||||
it("calls store.registerArtifact with mapped agent author input", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-register" }));
|
||||
@@ -344,6 +410,40 @@ describe("chat artifact tools", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers with explicit task_id, decoded image bytes, and fixed dashboard-chat author", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
const imageBytes = Buffer.from("chat-image-bytes");
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({
|
||||
id: "art-chat-image",
|
||||
authorId: "dashboard-chat",
|
||||
taskId: "FN-3030",
|
||||
type: "image",
|
||||
title: "Chat screenshot",
|
||||
mimeType: "image/png",
|
||||
uri: "artifacts/chat-screenshot.png",
|
||||
content: undefined,
|
||||
}));
|
||||
const { messageStore } = createMockMessageStore();
|
||||
|
||||
const tool = findChatTool("fn_artifact_register", store, messageStore);
|
||||
const result = await runTool(tool, "call-chat-register-image", {
|
||||
task_id: "FN-3030",
|
||||
type: "image",
|
||||
title: "Chat screenshot",
|
||||
mimeType: "image/png",
|
||||
dataBase64: imageBytes.toString("base64"),
|
||||
});
|
||||
|
||||
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskId: "FN-3030",
|
||||
authorId: "dashboard-chat",
|
||||
authorType: "agent",
|
||||
title: "Chat screenshot",
|
||||
data: imageBytes,
|
||||
}));
|
||||
expect(getText(result)).toContain("Registered artifact");
|
||||
});
|
||||
|
||||
it("registers with explicit task_id and fixed dashboard-chat author", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-chat", authorId: "dashboard-chat", taskId: "FN-3030" }));
|
||||
|
||||
@@ -123,6 +123,7 @@ export const artifactRegisterParams = Type.Object({
|
||||
mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })),
|
||||
uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })),
|
||||
content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })),
|
||||
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content and uri when provided." })),
|
||||
taskId: Type.Optional(Type.String({ description: "Optional associated task ID (e.g. 'FN-001')." })),
|
||||
});
|
||||
|
||||
@@ -146,6 +147,7 @@ export const chatArtifactRegisterParams = Type.Object({
|
||||
mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })),
|
||||
uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })),
|
||||
content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })),
|
||||
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content and uri when provided." })),
|
||||
task_id: Type.String({ description: "Associated task ID (e.g. 'FN-001')." }),
|
||||
});
|
||||
|
||||
@@ -1350,7 +1352,7 @@ export function createArtifactRegisterTool(store: TaskStore, authorId: string, m
|
||||
label: "Register Artifact",
|
||||
description:
|
||||
"Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it. " +
|
||||
"Provide either inline content or a uri/path reference; optionally associate it with a taskId.",
|
||||
"Provide inline content, a uri/path reference, or dataBase64 image bytes; optionally associate it with a taskId.",
|
||||
parameters: artifactRegisterParams,
|
||||
execute: async (_id: string, params: Static<typeof artifactRegisterParams>) => registerArtifactForAgent(store, authorId, params, messageStore),
|
||||
};
|
||||
@@ -1397,7 +1399,7 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
|
||||
name: "fn_artifact_register",
|
||||
label: "Register Artifact",
|
||||
description:
|
||||
"Register an artifact for a specific task so other agents can discover it. Requires task_id and notifies the dashboard inbox best-effort.",
|
||||
"Register an artifact for a specific task so other agents can discover it. Requires task_id, accepts dataBase64 image bytes, and notifies the dashboard inbox best-effort.",
|
||||
parameters: chatArtifactRegisterParams,
|
||||
execute: async (_id: string, params: Static<typeof chatArtifactRegisterParams>) => registerArtifactForAgent(
|
||||
store,
|
||||
@@ -1409,6 +1411,7 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
|
||||
mimeType: params.mimeType,
|
||||
uri: params.uri,
|
||||
content: params.content,
|
||||
dataBase64: params.dataBase64,
|
||||
taskId: params.task_id,
|
||||
},
|
||||
messageStore,
|
||||
@@ -1439,19 +1442,21 @@ async function registerArtifactForAgent(
|
||||
params: Static<typeof artifactRegisterParams>,
|
||||
messageStore?: MessageStore,
|
||||
) {
|
||||
const input: ArtifactCreateInput = {
|
||||
type: params.type,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
mimeType: params.mimeType,
|
||||
uri: params.uri,
|
||||
content: params.content,
|
||||
authorId,
|
||||
authorType: "agent",
|
||||
taskId: params.taskId,
|
||||
};
|
||||
|
||||
try {
|
||||
const data = decodeArtifactDataBase64(params);
|
||||
const input: ArtifactCreateInput = {
|
||||
type: params.type,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
mimeType: params.mimeType,
|
||||
uri: params.uri,
|
||||
content: params.content,
|
||||
data,
|
||||
authorId,
|
||||
authorType: "agent",
|
||||
taskId: params.taskId,
|
||||
};
|
||||
|
||||
const artifact: Artifact = await store.registerArtifact(input);
|
||||
notifyArtifactRegistered(messageStore, artifact, authorId);
|
||||
return {
|
||||
@@ -1473,6 +1478,37 @@ async function registerArtifactForAgent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ArtifactRegistry 2026-06-29-00:00:
|
||||
* Agents need a portable way to create task-scoped image artifacts without reading arbitrary local files. `dataBase64` decodes inside the tool and then uses TaskStore's existing binary persistence path so registry rows continue to store only managed artifact URIs.
|
||||
*/
|
||||
function decodeArtifactDataBase64(params: Static<typeof artifactRegisterParams>): Buffer | undefined {
|
||||
const encoded = params.dataBase64?.trim();
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (params.uri || params.content) {
|
||||
throw new Error("dataBase64 cannot be combined with uri or content; provide exactly one artifact payload source.");
|
||||
}
|
||||
|
||||
if (params.type === "image" && !params.mimeType?.startsWith("image/")) {
|
||||
throw new Error("image artifacts registered with dataBase64 require an image/* mimeType such as image/png.");
|
||||
}
|
||||
|
||||
const normalized = encoded.replace(/\s+/g, "");
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
|
||||
throw new Error("dataBase64 must be valid base64-encoded artifact bytes.");
|
||||
}
|
||||
|
||||
const data = Buffer.from(normalized, "base64");
|
||||
if (data.length === 0 || data.toString("base64").replace(/=+$/, "") !== normalized.replace(/=+$/, "")) {
|
||||
throw new Error("dataBase64 must decode to non-empty artifact bytes.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function notifyArtifactRegistered(messageStore: MessageStore | undefined, artifact: Artifact, authorId: string): void {
|
||||
if (!messageStore) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user