feat: video, HTML mockup, and PDF artifact support end-to-end
Video was registrable but effectively unusable, and HTML/PDF deliverables had no first-class path from agents to the gallery. - media route now serves HTTP byte ranges (Accept-Ranges, 206 + Content-Range, 416 on unsatisfiable) so <video>/<audio> seeking works and Safari plays media at all - video attachments (mp4/webm/mov, 100MB cap vs 5MB for other types) bridge into the artifact registry like images; multer transport ceiling raised to 100MB with per-type caps enforced in the store - fn_artifact_register path payloads are signature-validated for video (ftyp box / EBML header) and PDF (%PDF- prefix), mirroring images - HTML doc artifacts (mimeType text/html) render as live sandboxed iframe previews by default in the doc viewer, with a Preview/Source toggle and the same FileEditor edit mode - executor/heartbeat/planning prompts and tool descriptions now cover the full type matrix: images, videos, audio, HTML mockups, PDFs, and markdown docs, each with the registration recipe Verified live: range requests (200/206/416) via curl, an ffmpeg-generated mp4 playing to completion in the gallery lightbox, and an interactive HTML mockup rendering in the sandboxed preview. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Agents now save screenshots/wireframes/mocks as artifacts, shown in a redesigned category gallery with doc editing.
|
||||
summary: Agents save screenshots, videos, HTML mockups, and PDFs as artifacts, shown in a new category gallery with doc editing.
|
||||
category: feature
|
||||
dev: fn_artifact_register gains a `path` payload source (file copied into managed storage, MIME inference, image signature validation) and is now always exposed to executor sessions (previously missing in ephemeral mode) with worktree-relative path resolution and executing-task default taskId; executor/planning prompts instruct agents to register visual deliverables; new `GET`/`PATCH /api/artifacts/:id` routes plus `TaskStore.updateArtifact` and the `artifact:updated` SSE event power in-place doc editing in the new ArtifactsGallery (Images/Docs/PDFs/Videos/Audio/Other sections with per-category viewers, mobile-responsive).
|
||||
dev: fn_artifact_register gains a `path` payload source (file copied into managed storage, MIME inference, image/video/PDF signature validation) and is now always exposed to executor sessions (previously missing in ephemeral mode) with worktree-relative path resolution and executing-task default taskId; executor/planning prompts instruct agents to register visual/media deliverables (images, videos, HTML mockups, PDFs); the media route serves HTTP byte ranges for video/audio seeking; video attachments (100MB cap) bridge into the registry like images; HTML docs render as live sandboxed previews; new `GET`/`PATCH /api/artifacts/:id` routes plus `TaskStore.updateArtifact` and the `artifact:updated` SSE event power in-place doc editing in the new ArtifactsGallery (Images/Docs/PDFs/Videos/Audio/Other sections with per-category viewers, mobile-responsive).
|
||||
|
||||
@@ -44,7 +44,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
|
||||
|
||||
Artifact tools operate on the shared artifact registry, so artifacts are visible across agents and tasks when the caller has the artifact ID or can discover it through filters.
|
||||
|
||||
- `fn_artifact_register` registers a `document`, `image`, `video`, `audio`, or `other` artifact with `title`, optional `description`, optional `mimeType`, and exactly one payload source: inline text `content`, a local file `path` (preferred for media the agent saved to disk — screenshots, wireframes, mockups, recordings; the file is copied into managed artifact storage with MIME inference and image signature validation), base64 `dataBase64` image bytes, or a `uri` reference for media stored elsewhere. Executor sessions resolve relative `path` values against the task worktree and default `taskId` to the executing task; task-scoped heartbeat sessions default `taskId` to the assigned task; dashboard chat uses the `dashboard-chat` author and requires `task_id` because chat has no ambient task.
|
||||
- `fn_artifact_register` registers a `document`, `image`, `video`, `audio`, or `other` artifact with `title`, optional `description`, optional `mimeType`, and exactly one payload source: inline text `content`, a local file `path` (preferred for media the agent saved to disk — screenshots, wireframes, mockups, screen recordings, PDF exports; the file is copied into managed artifact storage with MIME inference and image/video/PDF signature validation), base64 `dataBase64` image bytes, or a `uri` reference for media stored elsewhere. HTML mockups register as `type="document"` + `mimeType="text/html"` (inline `content` or `path`) and render as live sandboxed previews in the Artifacts view; PDFs (`mimeType="application/pdf"`, `path`) open in an embedded viewer; videos stream with range-request seeking. Executor sessions resolve relative `path` values against the task worktree and default `taskId` to the executing task; task-scoped heartbeat sessions default `taskId` to the assigned task; dashboard chat uses the `dashboard-chat` author and requires `task_id` because chat has no ambient task.
|
||||
- `fn_artifact_list` lists artifacts across agents and tasks with optional `type`, `authorId`, `taskId`, `search`, `limit`, and `offset` filters. Dashboard chat's scoped variant requires `task_id` and otherwise supports `type`, `authorId`, `search`, `limit`, and `offset` for that task.
|
||||
- `fn_artifact_view` fetches one artifact by `id`, returning registry metadata plus inline `content` when present or the stored `uri`/path reference for media artifacts.
|
||||
- Successful registration emits a best-effort `system` → `user` inbox notification to `DASHBOARD_USER_ID` with `artifactId`, `artifactType`, `title`, `authorId`, and optional `taskId` metadata. Notification delivery failures are logged and must never fail or roll back the artifact registration.
|
||||
|
||||
@@ -821,6 +821,8 @@ Features:
|
||||
- Use the tab-count badges to see the current counts for Project Files, Task Documents, and Artifacts; the Artifacts badge reflects the loaded `GET /api/artifacts` result set, including active search filters
|
||||
- Browse the category-driven gallery: artifacts are broken down into **Images**, **Docs**, **PDFs**, **Videos**, **Audio**, and **Other** content categories (PDFs are detected by MIME type/extension regardless of registry type). "All" renders one section per present category; the chip row filters to a single category, and chips only appear for categories that exist
|
||||
- Each category has a tailored experience: Images/Videos use a visual-first tile grid with hover metadata and a full-size lightbox; Docs open a full document viewer with rendered markdown; PDFs open an embedded viewer with an open-in-new-tab action; Audio renders inline player rows; Other renders compact download rows
|
||||
- Video artifacts (agent-registered recordings, `path`-ingested MP4/WebM/MOV, and bridged video attachments) play with working seek because the media route serves HTTP byte ranges
|
||||
- HTML doc artifacts (`mimeType: text/html`) render as **live sandboxed previews** by default in the document viewer (scripts allowed, same-origin denied), with a Preview/Source toggle and the same Edit mode as other docs
|
||||
- **Edit any inline-content doc in place**: the document viewer's **Edit** button switches to an editor whose **Save** persists through `PATCH /api/artifacts/:id` and live-refreshes open galleries via the `artifact:updated` registry event; binary-backed documents stay read-only with a media link
|
||||
- Dismiss any viewer with the close button, backdrop click, or Escape
|
||||
- Read artifact metadata on cards, rows, and viewer footers: title, optional description, author ID, timestamp, size, and linked task ID when present
|
||||
|
||||
Binary file not shown.
BIN
docs/screenshots/artifacts-html-preview.png
LFS
Normal file
BIN
docs/screenshots/artifacts-html-preview.png
LFS
Normal file
Binary file not shown.
BIN
docs/screenshots/artifacts-video-lightbox.png
LFS
Normal file
BIN
docs/screenshots/artifacts-video-lightbox.png
LFS
Normal file
Binary file not shown.
@@ -76,11 +76,12 @@
|
||||
|
||||
- `artifacts` is the first-class metadata registry for generated or uploaded task artifacts. Rows store ID, `type` (`document`, `image`, `video`, `audio`, or `other`), title/description, MIME type, size, author identity/type, optional task linkage, metadata JSON, textual `content`, a relative `uri`, and timestamps; binary bytes are not stored in SQLite.
|
||||
- `TaskStore.registerArtifact()` writes task-scoped binary payloads under `<rootDir>/.fusion/tasks/{ID}/artifacts/` and task-less registry payloads under `<rootDir>/.fusion/artifacts/`, then records a relative `artifacts/<file>` URI in SQLite. If the DB insert fails after a binary write, the store removes the orphaned file before surfacing the error.
|
||||
- Image task attachments (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) are bridged into the artifact registry by `TaskStore.addAttachment()` as `image` rows with `metadata.source: "attachment"` and a relative `attachments/<file>` URI. This keeps one copy of the bytes under `<rootDir>/.fusion/tasks/{ID}/attachments/` while making the image discoverable through artifact list APIs and the Documents/Task Artifacts galleries. Non-image attachments remain attachment-only. Deleting an attachment also deletes its bridged artifact row before removing the attachment file so `/api/artifacts/:id/media` does not point at a deleted attachment.
|
||||
- Image task attachments (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) and video task attachments (`video/mp4`, `video/webm`, `video/quicktime`; 100MB cap vs 5MB for other attachments) are bridged into the artifact registry by `TaskStore.addAttachment()` as `image`/`video` rows with `metadata.source: "attachment"` and a relative `attachments/<file>` URI. This keeps one copy of the bytes under `<rootDir>/.fusion/tasks/{ID}/attachments/` while making the image discoverable through artifact list APIs and the Documents/Task Artifacts galleries. Non-image attachments remain attachment-only. Deleting an attachment also deletes its bridged artifact row before removing the attachment file so `/api/artifacts/:id/media` does not point at a deleted attachment.
|
||||
- Inline text/document artifacts may store `content` directly in SQLite and therefore have no media file. The dashboard media route streams `GET /api/artifacts/:id/media` from disk when `uri` is present, accepting task-scoped artifact URIs under `artifacts/` and bridged image-attachment URIs under `attachments/`, or returns inline `content` with the persisted MIME type when no `uri` exists.
|
||||
- `getArtifact(id)` returns metadata by ID, `getArtifacts(taskId)` returns active-task artifacts newest-first, and `listArtifacts(...)` is the cross-agent query path with type/author/task/search filters and pagination. List reads hide artifacts whose parent task is soft-deleted while preserving task-less artifacts.
|
||||
- `updateArtifact(id, { title?, description?, content? })` powers the dashboard Artifacts view's in-place doc editing (`GET`/`PATCH /api/artifacts/:id`). Content edits are only allowed on inline-content rows (no `uri`); binary-backed rows accept metadata edits only, archived-task artifacts stay read-only, and successful updates emit `artifact:updated` for live gallery refresh.
|
||||
- `fn_artifact_register` accepts a local file `path` (in addition to inline `content`/`dataBase64`): the tool reads the file (50 MB cap), infers the MIME type from the extension when omitted, signature-validates image payloads (PNG/JPEG/GIF/WebP magic bytes, SVG text sniff), and persists the bytes through `registerArtifact()`'s managed storage path so the registry row keeps a servable URI after worktrees are cleaned up. Executor-lane registrations resolve relative paths against the task worktree and default `taskId` to the executing task.
|
||||
- `fn_artifact_register` accepts a local file `path` (in addition to inline `content`/`dataBase64`): the tool reads the file (50 MB cap), infers the MIME type from the extension when omitted, signature-validates image payloads (PNG/JPEG/GIF/WebP magic bytes, SVG text sniff), video payloads (mp4/mov `ftyp` box, WebM EBML header), and PDF payloads (`%PDF-` prefix), and persists the bytes through `registerArtifact()`'s managed storage path so the registry row keeps a servable URI after worktrees are cleaned up. Executor-lane registrations resolve relative paths against the task worktree and default `taskId` to the executing task. HTML mockups register as `type="document"` + `mimeType="text/html"` (via `content` or `path`) and render as live sandboxed previews in the Artifacts view.
|
||||
- `GET /api/artifacts/:id/media` serves HTTP byte ranges (`Accept-Ranges: bytes`, 206 + `Content-Range` for single ranges, 416 for unsatisfiable ranges) so `<video>`/`<audio>` seeking works and Safari plays media at all.
|
||||
- Task-linked artifact registration requires an active, non-archived task. Archived tasks are read-only for artifact writes; soft-deleted or missing tasks are rejected.
|
||||
- Retention follows the existing task lifecycle rather than a separate artifact policy: soft-deleted parent tasks keep artifact rows/files for forensics but normal live-reader APIs hide them; hard deletion from the active `tasks` table cascades artifact metadata through the `taskId` foreign key, and archive cleanup removes the task directory that contains task-scoped artifact binaries. Task-less artifacts live under `<rootDir>/.fusion/artifacts/` and are not tied to task archival cleanup.
|
||||
- Worktree DB hydration copies task-scoped artifact metadata for the current task/dependency graph alongside task rows and `task_documents`. It intentionally does not copy binary payload files, and it intentionally excludes task-less registry artifacts because dependency hydration is scoped to the active task graph.
|
||||
|
||||
@@ -56,7 +56,7 @@ This reference documents tools injected by the engine at runtime for specific ag
|
||||
| `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 it appears in the dashboard Artifacts gallery and other agents and tasks can discover it; media files saved to disk (screenshots, wireframes, mockups, recordings) may provide `path` (copied into registry-managed storage; relative paths resolve against the executor worktree), and image artifacts may alternatively provide `dataBase64` bytes; executor/task-scoped-heartbeat registrations default `taskId` to the executing task | `type` (string), `title` (string), `description?` (string), `mimeType?` (string), `uri?` (string), `content?` (string), `dataBase64?` (base64 string), `path?` (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 it appears in the dashboard Artifacts gallery and other agents and tasks can discover it; media files saved to disk (screenshots, wireframes, mockups, screen recordings, PDFs) may provide `path` (copied into registry-managed storage; relative paths resolve against the executor worktree; image/video/PDF payloads are signature-validated), HTML mockups use `type="document"` + `mimeType="text/html"` with `content` or `path` and render as live sandboxed previews, and image artifacts may alternatively provide `dataBase64` bytes; executor/task-scoped-heartbeat registrations default `taskId` to the executing task | `type` (string), `title` (string), `description?` (string), `mimeType?` (string), `uri?` (string), `content?` (string), `dataBase64?` (base64 string), `path?` (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) |
|
||||
|
||||
|
||||
@@ -424,7 +424,7 @@ For bug-class/bug-fix tasks, add and fill in the exact \`## Symptom Verification
|
||||
|
||||
- [ ] Update relevant documentation
|
||||
- [ ] Save documentation deliverables as task documents via \`fn_task_document_write\` (key="docs", content=...)
|
||||
- [ ] For UI-visible changes or design deliverables: register screenshots/wireframes/mockups as image artifacts via \`fn_artifact_register(type="image", title=..., path="<saved file>")\`
|
||||
- [ ] For UI-visible changes or design deliverables: register screenshots/wireframes/mockups as image artifacts via \`fn_artifact_register(type="image", title=..., path="<saved file>")\`; screen recordings as \`type="video"\` with \`path\`; interactive HTML mockups as \`type="document"\` with \`mimeType="text/html"\` (rendered as live previews); PDF exports as \`type="document"\` with \`mimeType="application/pdf"\` and \`path\`
|
||||
- [ ] Out-of-scope findings created as new tasks via \`fn_task_create\` tool
|
||||
|
||||
## Documentation Requirements
|
||||
@@ -579,7 +579,7 @@ For source-free forensic or spec-compliance tasks whose only deliverables are gi
|
||||
- Steps should express OUTCOMES, not micro-instructions (2-5 checkboxes per step)
|
||||
- Always include a testing step and a documentation step
|
||||
- For tasks whose primary deliverable is documentation (updating docs, writing README, API references), include an explicit step or checkbox instructing the executor to save the final documentation content via \`fn_task_document_write\`
|
||||
- For tasks with a visible UI surface or whose deliverable is visual (wireframes, mockups, designs, diagrams, screenshots), include an explicit step or checkbox instructing the executor to save each visual to disk and register it via \`fn_artifact_register(type="image", title=..., path=...)\` so it appears in the dashboard Artifacts gallery
|
||||
- For tasks with a visible UI surface or whose deliverable is visual/media (wireframes, mockups, designs, diagrams, screenshots, screen recordings, HTML prototypes, PDF exports), include an explicit step or checkbox instructing the executor to save each deliverable to disk and register it via \`fn_artifact_register\` (images via \`type="image", path=...\`; recordings via \`type="video", path=...\`; HTML mockups via \`type="document", mimeType="text/html"\` for live gallery previews; PDFs via \`type="document", mimeType="application/pdf", path=...\`) so it appears in the dashboard Artifacts gallery
|
||||
- Include a "Do NOT" section with project-appropriate guardrails
|
||||
- Size assessment: S (<{{triageSizeSmallMaxHours}}h), M ({{triageSizeSmallMaxHours}}-{{triageSizeMediumMaxHours}}h), L ({{triageSizeMediumMaxHours}}-{{triageSizeLargeMaxHours}}h). Split if XL ({{triageSizeLargeMaxHours}}h+)
|
||||
- Review level scoring: Blast radius (0-2), Pattern novelty (0-2), Security (0-2), Reversibility (0-2)
|
||||
|
||||
@@ -13158,6 +13158,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
// FNXC:ArtifactRegistry 2026-07-11-10:20: video attachments (screen recordings, demo reels) are first-class — they bridge into the artifact registry and stream through the range-aware media route.
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/json",
|
||||
@@ -13168,6 +13172,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
]);
|
||||
|
||||
private static MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
// FNXC:ArtifactRegistry 2026-07-11-10:20: videos get a larger cap than other attachments — a 5MB ceiling cannot hold even a short screen recording.
|
||||
private static MAX_VIDEO_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
async addAttachment(
|
||||
id: string,
|
||||
@@ -13180,9 +13186,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
`Invalid mime type '${mimeType}'. Allowed: ${[...TaskStore.ALLOWED_MIME_TYPES].join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (content.length > TaskStore.MAX_ATTACHMENT_SIZE) {
|
||||
const maxSize = mimeType.startsWith("video/") ? TaskStore.MAX_VIDEO_ATTACHMENT_SIZE : TaskStore.MAX_ATTACHMENT_SIZE;
|
||||
if (content.length > maxSize) {
|
||||
throw new Error(
|
||||
`File too large (${content.length} bytes). Maximum: ${TaskStore.MAX_ATTACHMENT_SIZE} bytes (5MB)`,
|
||||
`File too large (${content.length} bytes). Maximum: ${maxSize} bytes (${maxSize / (1024 * 1024)}MB)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13216,19 +13223,23 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
||||
return attachment;
|
||||
});
|
||||
|
||||
if (mimeType.startsWith("image/")) {
|
||||
if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) {
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-07-10-00:00:
|
||||
* FN-7791 requires image task attachments created by agents, dashboard uploads, and route callers to surface as normal image artifacts. Register a URI-only artifact that points at the already-written attachment file so the proven artifact listing/SSE/media pipeline is reused without duplicating bytes or re-entering addAttachment.
|
||||
*
|
||||
* FNXC:ArtifactRegistry 2026-07-11-10:20:
|
||||
* Video attachments bridge the same way so uploaded/agent-attached recordings surface in the Artifacts gallery's Videos section and stream through the range-aware media route.
|
||||
*
|
||||
* FNXC:ArtifactRegistry 2026-07-10-00:00:
|
||||
* registerArtifact() enforces the artifact-registry active/non-archived task rule (see registerArtifact's ACTIVE_TASKS_WHERE check), but addAttachment has never enforced that rule for attachments themselves — attachments may be added to archived or soft-deleted tasks. Without this guard, attaching an image to an archived/soft-deleted task would throw here AFTER the attachment file and task.json were already written, so the caller would see addAttachment fail even though the attachment actually succeeded. Bridging into the artifact registry is best-effort: swallow the expected archived/not-found rejection so addAttachment keeps its existing always-succeeds-for-a-valid-image contract, and only the artifact-gallery bridge is skipped.
|
||||
*/
|
||||
const bridgeType = mimeType.startsWith("video/") ? "video" as const : "image" as const;
|
||||
try {
|
||||
await this.registerArtifact({
|
||||
type: "image",
|
||||
type: bridgeType,
|
||||
title: attachment.originalName,
|
||||
description: "Image task attachment",
|
||||
description: bridgeType === "video" ? "Video task attachment" : "Image task attachment",
|
||||
mimeType,
|
||||
sizeBytes: attachment.size,
|
||||
uri: `attachments/${attachment.filename}`,
|
||||
|
||||
@@ -423,6 +423,15 @@ grids drop to two columns, cards/rows go single-column, and viewers become full-
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* HTML mockup live preview; white canvas because agent mockups usually assume a page background. */
|
||||
.artifacts-gallery-viewer-html {
|
||||
width: 100%;
|
||||
height: 60dvh;
|
||||
border: thin solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer-doc {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
@@ -610,6 +610,14 @@ function DocViewer({ artifact, projectId, t, addToast, onClose, onOpenTask, onAr
|
||||
|
||||
const editable = detail !== null && !detail.uri;
|
||||
const content = detail?.content ?? "";
|
||||
/*
|
||||
FNXC:ArtifactsGallery 2026-07-11-10:20:
|
||||
HTML doc artifacts (agent-authored mockups/prototypes) must render as LIVE web previews by
|
||||
default — not as markdown — in a sandboxed iframe (scripts allowed, same-origin denied so the
|
||||
mockup cannot reach the dashboard API). The Preview/Source toggle replaces Markdown/Plain for
|
||||
HTML, and Edit still opens the shared FileEditor.
|
||||
*/
|
||||
const isHtml = (detail?.mimeType ?? artifact.mimeType)?.toLowerCase().split(";", 1)[0] === "text/html";
|
||||
|
||||
const startEditing = () => {
|
||||
setDraft(content);
|
||||
@@ -653,9 +661,13 @@ function DocViewer({ artifact, projectId, t, addToast, onClose, onOpenTask, onAr
|
||||
className="btn btn-sm"
|
||||
onClick={() => setRenderMarkdown((prev) => !prev)}
|
||||
aria-pressed={renderMarkdown}
|
||||
title={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
title={isHtml
|
||||
? (renderMarkdown ? t("documents.switchToSource", "Switch to source view") : t("documents.switchToPreview", "Switch to live preview"))
|
||||
: (renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown"))}
|
||||
>
|
||||
{renderMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
|
||||
{isHtml
|
||||
? (renderMarkdown ? t("documents.htmlPreview", "Preview") : t("documents.htmlSource", "Source"))
|
||||
: (renderMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain"))}
|
||||
</button>
|
||||
{editable && (
|
||||
<button className="btn btn-sm" onClick={startEditing} aria-label={t("documents.editArtifact", "Edit document")}>
|
||||
@@ -680,6 +692,13 @@ function DocViewer({ artifact, projectId, t, addToast, onClose, onOpenTask, onAr
|
||||
forceToolbarActionsVisible
|
||||
/>
|
||||
</div>
|
||||
) : isHtml && renderMarkdown ? (
|
||||
<iframe
|
||||
className="artifacts-gallery-viewer-html"
|
||||
sandbox="allow-scripts"
|
||||
title={title}
|
||||
{...(detail.uri ? { src: artifactMediaUrl(artifact.id, projectId) } : { srcDoc: content })}
|
||||
/>
|
||||
) : detail.uri ? (
|
||||
<p className="artifacts-gallery-viewer-loading">
|
||||
{t("documents.binaryDocArtifact", "This document is stored as a file.")}{" "}
|
||||
|
||||
@@ -617,6 +617,50 @@ describe("DocumentsView", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("Artifact saved", "success");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsGallery 2026-07-11-10:20:
|
||||
HTML doc artifacts must open as LIVE sandboxed previews by default (agents deliver interactive mockups as text/html documents), with a Source toggle for the raw markup.
|
||||
*/
|
||||
it("renders HTML doc artifacts as a sandboxed live preview with a source toggle", async () => {
|
||||
const htmlArtifact: ArtifactWithTask = {
|
||||
id: "artifact-html",
|
||||
type: "document",
|
||||
title: "Login mockup",
|
||||
mimeType: "text/html",
|
||||
content: "<h1>Login mock</h1>",
|
||||
authorId: "design-agent",
|
||||
authorType: "agent",
|
||||
createdAt: "2026-04-19T09:30:00.000Z",
|
||||
updatedAt: "2026-04-19T09:30:00.000Z",
|
||||
};
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: [...mockArtifacts, htmlArtifact],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockFetchArtifact.mockResolvedValue(htmlArtifact);
|
||||
|
||||
const { container } = render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
|
||||
const htmlCard = screen.getByRole("article", { name: "Artifact Login mockup" });
|
||||
fireEvent.click(within(htmlCard).getByRole("button", { name: "Open Login mockup" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Document artifact viewer" });
|
||||
|
||||
await waitFor(() => {
|
||||
const iframe = container.querySelector(".artifacts-gallery-viewer-html");
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe).toHaveAttribute("sandbox", "allow-scripts");
|
||||
expect(iframe).toHaveAttribute("srcdoc", "<h1>Login mock</h1>");
|
||||
});
|
||||
|
||||
// The toggle shows the CURRENT mode (matching the Markdown/Plain convention): "Preview" while previewing.
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Preview" }));
|
||||
expect(container.querySelector(".artifacts-gallery-viewer-html")).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByText("<h1>Login mock</h1>")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking project file shows content", async () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
|
||||
@@ -255,9 +255,13 @@ export interface AuthStorageLike {
|
||||
get?(providerId: string): { type?: string; key?: string; access?: string; refresh?: string; expires?: number; [key: string]: unknown } | null | undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-11-10:20:
|
||||
The multer ceiling only guards transport; the real per-type caps live in TaskStore.addAttachment (5MB non-video, 100MB video). Raised from 5MB so video attachments (screen recordings, demo reels) can reach the store at all.
|
||||
*/
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB transport ceiling; store enforces per-type caps
|
||||
});
|
||||
|
||||
// Async variants — sync fs.* on a settings route blocks every concurrent
|
||||
|
||||
@@ -71,6 +71,32 @@ describe("artifacts route integration", () => {
|
||||
return { task, artifact, imageBytes };
|
||||
}
|
||||
|
||||
async function requestRawBufferWithHeaders(app: express.Express, path: string, headers: Record<string, string>) {
|
||||
const server = http.createServer(app);
|
||||
return await new Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }>((resolve, reject) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Expected an ephemeral TCP address for raw media request"));
|
||||
return;
|
||||
}
|
||||
|
||||
const req = http.get({ host: "127.0.0.1", port: address.port, path, headers }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
server.close();
|
||||
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks) });
|
||||
});
|
||||
});
|
||||
req.on("error", (error) => {
|
||||
server.close();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requestRawBuffer(app: express.Express, path: string) {
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-06-29-17:11:
|
||||
@@ -570,4 +596,68 @@ describe("artifacts route integration", () => {
|
||||
const missingGet = await REQUEST(app, "GET", "/api/artifacts/does-not-exist");
|
||||
expect(missingGet.status).toBe(404);
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-07-11-10:20:
|
||||
* Video playback contract: the media route must serve HTTP byte ranges (206 + Content-Range +
|
||||
* Accept-Ranges) because <video> seeking issues Range requests and Safari refuses to play from
|
||||
* servers that ignore them. Unsatisfiable ranges answer 416.
|
||||
*/
|
||||
it("serves byte-range requests for video artifact media", async () => {
|
||||
const task = await store.createTask({ title: "Demo recording", description: "range test" });
|
||||
const videoBytes = Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from("ftypmp42-0123456789abcdef")]);
|
||||
const artifact = await store.registerArtifact({
|
||||
type: "video",
|
||||
title: "Feature demo",
|
||||
mimeType: "video/mp4",
|
||||
data: videoBytes,
|
||||
authorId: "agent-video",
|
||||
authorType: "agent",
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
const full = await requestRawBuffer(app, `/api/artifacts/${artifact.id}/media`);
|
||||
expect(full.status).toBe(200);
|
||||
expect(full.headers["accept-ranges"]).toBe("bytes");
|
||||
expect(full.headers["content-length"]).toBe(String(videoBytes.length));
|
||||
expect(full.body).toEqual(videoBytes);
|
||||
|
||||
const ranged = await requestRawBufferWithHeaders(app, `/api/artifacts/${artifact.id}/media`, { Range: "bytes=4-11" });
|
||||
expect(ranged.status).toBe(206);
|
||||
expect(ranged.headers["content-range"]).toBe(`bytes 4-11/${videoBytes.length}`);
|
||||
expect(ranged.body).toEqual(videoBytes.subarray(4, 12));
|
||||
|
||||
const suffix = await requestRawBufferWithHeaders(app, `/api/artifacts/${artifact.id}/media`, { Range: "bytes=-5" });
|
||||
expect(suffix.status).toBe(206);
|
||||
expect(suffix.body).toEqual(videoBytes.subarray(videoBytes.length - 5));
|
||||
|
||||
const openEnded = await requestRawBufferWithHeaders(app, `/api/artifacts/${artifact.id}/media`, { Range: `bytes=10-` });
|
||||
expect(openEnded.status).toBe(206);
|
||||
expect(openEnded.body).toEqual(videoBytes.subarray(10));
|
||||
|
||||
const unsatisfiable = await requestRawBufferWithHeaders(app, `/api/artifacts/${artifact.id}/media`, { Range: `bytes=${videoBytes.length + 5}-` });
|
||||
expect(unsatisfiable.status).toBe(416);
|
||||
expect(unsatisfiable.headers["content-range"]).toBe(`bytes */${videoBytes.length}`);
|
||||
});
|
||||
|
||||
it("bridges a video attachment into the artifact registry and streams it", async () => {
|
||||
const task = await store.createTask({ title: "Video attachment", description: "bridge test" });
|
||||
const videoBytes = Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from("ftypmp42-attachment-video")]);
|
||||
await store.addAttachment(task.id, "walkthrough.mp4", videoBytes, "video/mp4");
|
||||
|
||||
const listRes = await REQUEST(app, "GET", `/api/artifacts?taskId=${task.id}`);
|
||||
expect(listRes.status).toBe(200);
|
||||
const bridged = (listRes.body as ArtifactWithTask[]).find((a) => a.type === "video");
|
||||
expect(bridged).toMatchObject({
|
||||
type: "video",
|
||||
title: "walkthrough.mp4",
|
||||
mimeType: "video/mp4",
|
||||
authorType: "system",
|
||||
});
|
||||
|
||||
const media = await requestRawBuffer(app, `/api/artifacts/${bridged!.id}/media`);
|
||||
expect(media.status).toBe(200);
|
||||
expect(media.headers["content-type"]).toBe("video/mp4");
|
||||
expect(media.body).toEqual(videoBytes);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createReadStream, statSync } from "node:fs";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import type {
|
||||
TaskStore,
|
||||
@@ -3839,7 +3839,51 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
throw notFound("Artifact media not found");
|
||||
}
|
||||
|
||||
const stream = createReadStream(mediaPath);
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-11-10:20:
|
||||
Video (and audio) playback requires HTTP byte-range serving: <video> seeking issues Range
|
||||
requests, and Safari refuses to play media at all from a server that ignores them. Serve
|
||||
single-range requests with 206 + Content-Range, advertise Accept-Ranges on full responses,
|
||||
and answer unsatisfiable ranges with 416 so players fail cleanly instead of hanging.
|
||||
*/
|
||||
let fileSize: number;
|
||||
try {
|
||||
fileSize = statSync(mediaPath).size;
|
||||
} catch {
|
||||
throw notFound("Artifact media not found");
|
||||
}
|
||||
|
||||
const mimeType = artifact.mimeType ?? "application/octet-stream";
|
||||
const rangeHeader = req.headers.range;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
|
||||
let start = 0;
|
||||
let end = fileSize - 1;
|
||||
let status = 200;
|
||||
if (typeof rangeHeader === "string") {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
||||
if (match && (match[1] !== "" || match[2] !== "")) {
|
||||
if (match[1] === "") {
|
||||
// suffix range: last N bytes
|
||||
const suffixLength = Number(match[2]);
|
||||
start = Math.max(0, fileSize - suffixLength);
|
||||
} else {
|
||||
start = Number(match[1]);
|
||||
if (match[2] !== "") {
|
||||
end = Math.min(Number(match[2]), fileSize - 1);
|
||||
}
|
||||
}
|
||||
if (start >= fileSize || start > end) {
|
||||
res.status(416).setHeader("Content-Range", `bytes */${fileSize}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
status = 206;
|
||||
res.setHeader("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
||||
}
|
||||
}
|
||||
|
||||
const stream = createReadStream(mediaPath, status === 206 ? { start, end } : undefined);
|
||||
stream.on("error", () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(404).json({ error: "Artifact media not found" });
|
||||
@@ -3847,7 +3891,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
res.setHeader("Content-Type", artifact.mimeType ?? "application/octet-stream");
|
||||
res.status(status);
|
||||
res.setHeader("Content-Type", mimeType);
|
||||
res.setHeader("Content-Length", end - start + 1);
|
||||
stream.pipe(res);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -440,10 +440,12 @@ describe("artifact register tool path payloads", () => {
|
||||
expect(getText(result)).toContain("path cannot be combined with uri, content, or dataBase64");
|
||||
});
|
||||
|
||||
it("registers non-image media (video) from path with extension-inferred mimeType and no signature gate", async () => {
|
||||
it("registers video media from path with extension-inferred mimeType and container signature validation", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-video", type: "video", mimeType: "video/mp4", content: undefined, uri: "artifacts/demo.mp4" }));
|
||||
writeFileSync(join(baseDir, "demo.mp4"), Buffer.from("fake-video-bytes"));
|
||||
// Minimal ISO BMFF header: 4-byte box size then "ftyp".
|
||||
const mp4Bytes = Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from("ftypmp42-demo-recording")]);
|
||||
writeFileSync(join(baseDir, "demo.mp4"), mp4Bytes);
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID, undefined, { baseDir, defaultTaskId: TASK_ID });
|
||||
const result = await runTool(tool, "call-path-video", {
|
||||
@@ -456,7 +458,55 @@ describe("artifact register tool path payloads", () => {
|
||||
type: "video",
|
||||
mimeType: "video/mp4",
|
||||
taskId: TASK_ID,
|
||||
data: Buffer.from("fake-video-bytes"),
|
||||
data: mp4Bytes,
|
||||
}));
|
||||
expect(getText(result)).not.toContain("ERROR:");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-11-10:20:
|
||||
Video and PDF path payloads are signature-gated like images so the gallery never receives an unplayable "video" or unrenderable "PDF"; WebM validates via its EBML header, mp4/mov via the ftyp box, PDFs via the %PDF- prefix.
|
||||
*/
|
||||
it("rejects renamed junk for video and pdf payloads but accepts valid containers", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID, undefined, { baseDir });
|
||||
writeFileSync(join(baseDir, "fake.mp4"), "not a real video");
|
||||
writeFileSync(join(baseDir, "fake.pdf"), "not a real pdf");
|
||||
writeFileSync(join(baseDir, "real.webm"), Buffer.concat([Buffer.from("1a45dfa3", "hex"), Buffer.from("webm-body")]));
|
||||
writeFileSync(join(baseDir, "real.pdf"), "%PDF-1.4\nminimal pdf body");
|
||||
|
||||
const fakeVideo = await runTool(tool, "call-fake-video", { type: "video", title: "Fake video", path: "fake.mp4" });
|
||||
const fakePdf = await runTool(tool, "call-fake-pdf", { type: "document", title: "Fake PDF", path: "fake.pdf" });
|
||||
expect(registerArtifact).not.toHaveBeenCalled();
|
||||
expect(getText(fakeVideo)).toContain("does not contain valid video bytes");
|
||||
expect(getText(fakePdf)).toContain("does not contain valid PDF bytes");
|
||||
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-webm", type: "video", mimeType: "video/webm", content: undefined, uri: "artifacts/real.webm" }));
|
||||
const realWebm = await runTool(tool, "call-real-webm", { type: "video", title: "Real WebM", path: "real.webm" });
|
||||
expect(getText(realWebm)).not.toContain("ERROR:");
|
||||
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-pdf", type: "document", mimeType: "application/pdf", content: undefined, uri: "artifacts/real.pdf" }));
|
||||
const realPdf = await runTool(tool, "call-real-pdf", { type: "document", title: "Real PDF", path: "real.pdf" });
|
||||
expect(getText(realPdf)).not.toContain("ERROR:");
|
||||
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ mimeType: "application/pdf" }));
|
||||
});
|
||||
|
||||
it("registers an HTML mockup from path with text/html mimeType inferred", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-html", type: "document", mimeType: "text/html", content: undefined, uri: "artifacts/mock.html" }));
|
||||
writeFileSync(join(baseDir, "mock.html"), "<!doctype html><html><body><h1>Login mock</h1></body></html>");
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID, undefined, { baseDir, defaultTaskId: TASK_ID });
|
||||
const result = await runTool(tool, "call-path-html", {
|
||||
type: "document",
|
||||
title: "Login page mockup",
|
||||
path: "mock.html",
|
||||
});
|
||||
|
||||
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "document",
|
||||
mimeType: "text/html",
|
||||
taskId: TASK_ID,
|
||||
}));
|
||||
expect(getText(result)).not.toContain("ERROR:");
|
||||
});
|
||||
|
||||
@@ -544,7 +544,7 @@ You have coding-capable workspace tools (read/write/edit/bash within worktree bo
|
||||
- fn_list_agents and fn_delegate_task
|
||||
- fn_get_agent_config and fn_update_agent_config (for direct reports only)
|
||||
- fn_agent_create and fn_agent_delete (for direct reports only)
|
||||
- fn_artifact_register, fn_artifact_list, and fn_artifact_view (register visual outputs — screenshots, wireframes, mockups, diagrams — as type="image" artifacts via the \`path\` parameter so they appear in the dashboard Artifacts gallery)
|
||||
- fn_artifact_register, fn_artifact_list, and fn_artifact_view (register visual/media outputs so they appear in the dashboard Artifacts gallery: screenshots/wireframes/mockups/diagrams as type="image" via \`path\`; screen recordings as type="video" via \`path\`; HTML mockups as type="document" with mimeType="text/html" — rendered as live previews; PDFs as type="document" with mimeType="application/pdf" via \`path\`)
|
||||
- fn_read_evaluations and fn_update_identity (available in no-task runs)
|
||||
- fn_reflect_on_performance when reflection is enabled for this run
|
||||
- fn_workflow_list, fn_workflow_get, fn_workflow_create, fn_workflow_update, fn_workflow_delete, fn_workflow_settings, and fn_trait_list for workflow discovery/authoring
|
||||
|
||||
@@ -1522,8 +1522,9 @@ export function createArtifactRegisterTool(
|
||||
label: "Register Artifact",
|
||||
description:
|
||||
"Register an artifact (document, image, video, audio, or other) so it appears in the dashboard Artifacts gallery and other agents and tasks can discover it. " +
|
||||
"For visual deliverables you saved to disk (screenshots, wireframes, mockups, diagrams, recordings), pass `path` — the file is copied into managed artifact storage. " +
|
||||
"Alternatively provide inline `content` for text documents or `dataBase64` image bytes; optionally associate the artifact with a taskId.",
|
||||
"For media you saved to disk (screenshots, wireframes, mockups, screen recordings, PDFs), pass `path` — the file is copied into managed artifact storage. " +
|
||||
"HTML mockups (type=document, mimeType=text/html, content or path) render as live sandboxed previews; PDFs (mimeType=application/pdf, path) open in an embedded viewer; videos play with seeking. " +
|
||||
"Alternatively provide inline `content` for text/markdown/HTML documents or `dataBase64` image bytes; optionally associate the artifact with a taskId.",
|
||||
parameters: artifactRegisterParams,
|
||||
execute: async (_id: string, params: Static<typeof artifactRegisterParams>) => registerArtifactForAgent(store, authorId, params, messageStore, options),
|
||||
};
|
||||
@@ -1570,7 +1571,7 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
|
||||
name: "fn_artifact_register",
|
||||
label: "Register Artifact",
|
||||
description:
|
||||
"Register an artifact for a specific task so it appears in the dashboard Artifacts gallery and other agents can discover it. Requires task_id; accepts a local file `path` (screenshots, wireframes, mockups) or dataBase64 image bytes, and notifies the dashboard inbox best-effort.",
|
||||
"Register an artifact for a specific task so it appears in the dashboard Artifacts gallery and other agents can discover it. Requires task_id; accepts a local file `path` (screenshots, wireframes, mockups, recordings, PDFs), inline `content` (text/markdown/HTML — HTML renders as a live preview), or dataBase64 image bytes, and notifies the dashboard inbox best-effort.",
|
||||
parameters: chatArtifactRegisterParams,
|
||||
execute: async (_id: string, params: Static<typeof chatArtifactRegisterParams>) => registerArtifactForAgent(
|
||||
store,
|
||||
@@ -1753,9 +1754,41 @@ async function readArtifactFileFromPath(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-11-10:20:
|
||||
Video and PDF payloads get the same keep-the-gallery-playable treatment as images: a light
|
||||
container-signature check (mp4/mov ftyp box, WebM EBML header, %PDF- prefix) rejects renamed
|
||||
junk before it reaches the registry, where the dashboard viewer could not play or render it.
|
||||
*/
|
||||
if (params.type === "video") {
|
||||
if (!mimeType.startsWith("video/")) {
|
||||
throw new Error(`video artifacts require a video/* mimeType, got ${mimeType}.`);
|
||||
}
|
||||
if (!hasVideoSignature(data, mimeType)) {
|
||||
throw new Error(`path ${resolvedPath} does not contain valid video bytes matching mimeType ${mimeType}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType === "application/pdf" && !data.subarray(0, 5).equals(Buffer.from("%PDF-"))) {
|
||||
throw new Error(`path ${resolvedPath} does not contain valid PDF bytes (missing %PDF- header).`);
|
||||
}
|
||||
|
||||
return { data, mimeType };
|
||||
}
|
||||
|
||||
function hasVideoSignature(data: Buffer, mimeType: string): boolean {
|
||||
if (mimeType === "video/webm") {
|
||||
// EBML header shared by WebM/Matroska containers.
|
||||
return data.subarray(0, 4).equals(Buffer.from("1a45dfa3", "hex"));
|
||||
}
|
||||
if (mimeType === "video/mp4" || mimeType === "video/quicktime") {
|
||||
// ISO BMFF: box size (4 bytes) then "ftyp".
|
||||
return data.length >= 8 && data.subarray(4, 8).toString("ascii") === "ftyp";
|
||||
}
|
||||
// Unknown video containers pass; the mimeType prefix check already ran.
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidImagePayload(data: Buffer, mimeType: string): boolean {
|
||||
if (mimeType === "image/svg+xml") {
|
||||
const head = data.subarray(0, 4096).toString("utf8").trimStart();
|
||||
|
||||
@@ -1452,14 +1452,21 @@ Documents are versioned — each write creates a new revision. Use meaningful ke
|
||||
|
||||
Use \`fn_artifact_register\` to register multi-type artifacts for discovery across agents and tasks, \`fn_artifact_list\` to find registered artifacts by type/author/task/search, and \`fn_artifact_view\` to inspect artifact metadata plus inline content or URI references. Artifact registration sends a best-effort system inbox notification to the dashboard user; notification failures do not make registration fail.
|
||||
|
||||
**IMPORTANT — Register visual deliverables as image artifacts:** Whenever you produce a visual output — a screenshot of the app or a UI change, a wireframe, a design mockup, a diagram, a rendered chart, a before/after capture — you MUST register it so it appears in the dashboard Artifacts gallery:
|
||||
**IMPORTANT — Register visual and media deliverables as artifacts:** Whenever you produce a visual or media output — a screenshot of the app or a UI change, a wireframe, a design mockup, a diagram, a rendered chart, a before/after capture, a screen recording, an HTML prototype, or a PDF export — you MUST register it so it appears in the dashboard Artifacts gallery:
|
||||
|
||||
1. Save the file to disk in your worktree (e.g. \`screenshots/after.png\`).
|
||||
2. Call \`fn_artifact_register(type="image", title="Settings modal — after fix", description="What this shows and why it matters", path="screenshots/after.png")\`.
|
||||
|
||||
Relative paths resolve against your worktree, and the file is COPIED into managed storage — so register even files you do not commit, and register before the worktree is cleaned up. Supported image formats: PNG, JPEG, GIF, WebP, SVG. For video/audio recordings use \`type="video"\`/\`type="audio"\` with \`path\`; for HTML mockups or text deliverables use \`type="document"\` with inline \`content\`. Artifacts you register are associated with this task automatically.
|
||||
Relative paths resolve against your worktree, and the file is COPIED into managed storage — so register even files you do not commit, and register before the worktree is cleaned up. Artifacts you register are associated with this task automatically. Type cheat sheet:
|
||||
|
||||
Register visual evidence proactively for any UI-affecting task: capture at least one screenshot demonstrating the final result when the change has a visible surface. If the task asks for wireframes, mockups, or designs, the registered image artifacts ARE the deliverable.
|
||||
- **Images** (screenshots, wireframes, mockups, diagrams): \`type="image"\` with \`path\` — PNG, JPEG, GIF, WebP, or SVG.
|
||||
- **Videos** (screen recordings, demo reels): \`type="video"\` with \`path\` — MP4, WebM, or MOV. They play with seeking directly in the gallery.
|
||||
- **Audio**: \`type="audio"\` with \`path\` — MP3, WAV, or OGG.
|
||||
- **HTML mockups/prototypes**: \`type="document"\`, \`mimeType="text/html"\`, with inline \`content\` or \`path\` — they render as LIVE sandboxed web previews in the gallery, so a self-contained HTML file is a great way to deliver an interactive mock.
|
||||
- **PDFs** (spec exports, reports): \`type="document"\`, \`mimeType="application/pdf"\`, with \`path\` — they open in an embedded PDF viewer.
|
||||
- **Text/markdown deliverables**: \`type="document"\` with inline \`content\` — rendered as formatted markdown and editable by the user.
|
||||
|
||||
Register visual evidence proactively for any UI-affecting task: capture at least one screenshot demonstrating the final result when the change has a visible surface. If the task asks for wireframes, mockups, designs, HTML prototypes, or recordings, the registered artifacts ARE the deliverable.
|
||||
|
||||
**IMPORTANT — Save your deliverables as documents:** When your task produces written output (documentation, specifications, reports, API references, README updates, guides, or any other content), you MUST save that content as a task document using \`fn_task_document_write\`. Use a key that describes the deliverable (e.g., key="readme", key="api-docs", key="changelog"). Do this in addition to writing the file to disk — the document persists in the task for review even after the worktree is cleaned up.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user