FN-6779: add artifacts gallery to Documents
Adds a Documents-view artifact gallery backed by dashboard artifact registry APIs. - Add artifact listing and media-serving endpoints with project scoping, validation, and safe artifact path resolution. - Add client artifact API helpers, SWR caching, and a useArtifacts hook for background-refresh gallery data. - Extend Documents view with an Artifacts tab, media previews, localized labels, documentation, tests, and a published package changeset. Files changed: .changeset/fn-6779-artifacts-gallery.md | 5 + docs/dashboard-guide.md | 6 +- packages/dashboard/app/api/legacy.ts | 34 ++++ .../dashboard/app/components/DocumentsView.css | 142 ++++++++++++++++ .../dashboard/app/components/DocumentsView.tsx | 178 ++++++++++++++++++-- .../components/__tests__/DocumentsView.test.tsx | 163 +++++++++++++++++- .../app/hooks/__tests__/useArtifacts.test.ts | 136 +++++++++++++++ packages/dashboard/app/hooks/useArtifacts.ts | 145 ++++++++++++++++ packages/dashboard/app/utils/swrCache.ts | 1 + .../src/routes/__tests__/artifacts-routes.test.ts | 182 +++++++++++++++++++++ .../src/routes/register-task-workflow-routes.ts | 125 ++++++++++++++ packages/i18n/locales/en/app.json | 22 ++- packages/i18n/locales/es/app.json | 27 ++- packages/i18n/locales/fr/app.json | 27 ++- packages/i18n/locales/ko/app.json | 27 ++- packages/i18n/locales/zh-CN/app.json | 27 ++- packages/i18n/locales/zh-TW/app.json | 27 ++- scripts/line-count-baseline.json | 16 +- 18 files changed, 1255 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-6779 Fusion-Task-Lineage: 1d71e4be-4bfc-4706-9cbf-7214f91a79d1
This commit is contained in:
5
.changeset/fn-6779-artifacts-gallery.md
Normal file
5
.changeset/fn-6779-artifacts-gallery.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add dashboard artifact registry read APIs, client helpers, and a Documents-view Artifacts media gallery for images, videos, audio, documents, and generic artifacts.
|
||||
@@ -471,14 +471,16 @@ For per-run aggregation, `GET /api/agents/:id/runs/:runId/cited-goals` returns `
|
||||
|
||||
## Documents View
|
||||
|
||||
Documents view aggregates task documents and project markdown files.
|
||||
Documents view aggregates task documents, project markdown files, and registered artifacts.
|
||||
|
||||
Features:
|
||||
|
||||
- Group task documents by task ID (with revision history metadata)
|
||||
- Search documents across tasks
|
||||
- Open project markdown files with inline preview
|
||||
- Jump directly from a document group to the owning task detail modal
|
||||
- Browse the **Artifacts** tab for media registered by agents, users, or the system across tasks
|
||||
- Preview artifact images inline, play video and audio with native controls, read document previews, and open generic artifacts through their media URL
|
||||
- Jump directly from a document group or artifact card to the owning task detail modal when a task is linked
|
||||
- Toggle between raw text and rendered markdown using the **Markdown/Plain** button
|
||||
- Highlight text in raw or rendered project-file previews, choose **Add comment**, and send the file path, selected snippet, and your comment to the **New Task** dialog
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ import type {
|
||||
TaskDocument,
|
||||
TaskDocumentRevision,
|
||||
TaskDocumentWithTask,
|
||||
Artifact,
|
||||
ArtifactType,
|
||||
ArtifactWithTask,
|
||||
|
||||
Message,
|
||||
MessageMetadata,
|
||||
@@ -1474,6 +1477,37 @@ export interface MarkdownFileListResponse {
|
||||
files: MarkdownFileEntry[];
|
||||
}
|
||||
|
||||
export type { Artifact, ArtifactType, ArtifactWithTask };
|
||||
|
||||
export interface FetchArtifactsOptions {
|
||||
type?: ArtifactType;
|
||||
authorId?: string;
|
||||
taskId?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function fetchArtifacts(
|
||||
options?: FetchArtifactsOptions,
|
||||
projectId?: string,
|
||||
): Promise<ArtifactWithTask[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.type) params.set("type", options.type);
|
||||
if (options?.authorId) params.set("authorId", options.authorId);
|
||||
if (options?.taskId) params.set("taskId", options.taskId);
|
||||
if (options?.q) params.set("q", options.q);
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
||||
const queryString = params.toString();
|
||||
const path = `/artifacts${queryString ? `?${queryString}` : ""}`;
|
||||
return api<ArtifactWithTask[]>(withProjectId(path, projectId));
|
||||
}
|
||||
|
||||
export function artifactMediaUrl(id: string, projectId?: string): string {
|
||||
return buildApiUrl(withProjectId(`/artifacts/${encodeURIComponent(id)}/media`, projectId));
|
||||
}
|
||||
|
||||
export async function fetchAllDocuments(
|
||||
options?: FetchAllDocumentsOptions,
|
||||
projectId?: string,
|
||||
|
||||
@@ -617,6 +617,126 @@
|
||||
font-family: var(--font-primary);
|
||||
}
|
||||
|
||||
.documents-artifact-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: var(--space-md);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.documents-artifact-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.documents-artifact-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 12rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.documents-artifact-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 18rem;
|
||||
object-fit: contain;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.documents-artifact-audio {
|
||||
width: calc(100% - var(--space-lg));
|
||||
}
|
||||
|
||||
.documents-artifact-document,
|
||||
.documents-artifact-generic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
min-height: 12rem;
|
||||
padding: var(--space-lg);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.documents-artifact-document p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.documents-artifact-generic {
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.documents-artifact-generic:hover {
|
||||
color: var(--todo);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.documents-artifact-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.documents-artifact-header,
|
||||
.documents-artifact-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.documents-artifact-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--todo);
|
||||
background: color-mix(in srgb, var(--todo) 10%, transparent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.documents-artifact-author {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.documents-artifact-title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.documents-artifact-description {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.documents-artifact-task-link {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Documents View mobile */
|
||||
@@ -727,6 +847,28 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.documents-artifact-gallery,
|
||||
.documents-artifact-gallery--mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.documents-artifact-preview,
|
||||
.documents-artifact-document,
|
||||
.documents-artifact-generic {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.documents-artifact-meta,
|
||||
.documents-artifact-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.documents-artifact-task-link {
|
||||
width: 100%;
|
||||
min-height: calc(var(--space-xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
/* Document mode toggle: ensure adequate touch target on mobile */
|
||||
.document-mode-toggle {
|
||||
min-width: 36px;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import "./DocumentsView.css";
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
|
||||
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff, Package } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ArtifactType, ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
import { artifactMediaUrl, fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
import { useArtifacts } from "../hooks/useArtifacts";
|
||||
import { useDocuments } from "../hooks/useDocuments";
|
||||
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
|
||||
import { useSelectionComment } from "../hooks/useSelectionComment";
|
||||
@@ -15,7 +16,7 @@ import { LoadingSpinner } from "./LoadingSpinner";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
type DocumentsTab = "project" | "tasks";
|
||||
type DocumentsTab = "project" | "tasks" | "artifacts";
|
||||
|
||||
export interface DocumentsViewProps {
|
||||
projectId?: string;
|
||||
@@ -39,6 +40,12 @@ interface TaskGroupProps {
|
||||
onToggleMarkdown: (docId: string) => void;
|
||||
}
|
||||
|
||||
interface ArtifactCardProps {
|
||||
artifact: ArtifactWithTask;
|
||||
projectId?: string;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function formatTimestamp(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString();
|
||||
@@ -154,7 +161,7 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
|
||||
<button
|
||||
className="documents-group-task-link"
|
||||
onClick={() => onOpenTask(taskId)}
|
||||
aria-label={`Open task ${taskId}: ${taskTitle || t("documents.untitled", "Untitled")}`}
|
||||
aria-label={t("documents.openTaskAria", "Open task {{taskId}}: {{title}}", { taskId, title: taskTitle || t("documents.untitled", "Untitled") })}
|
||||
>
|
||||
{t("documents.openTask", "Open task")}
|
||||
</button>
|
||||
@@ -176,6 +183,83 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
|
||||
);
|
||||
}
|
||||
|
||||
function getArtifactTypeLabel(t: ReturnType<typeof useTranslation<"app">>["t"], type: ArtifactType): string {
|
||||
switch (type) {
|
||||
case "image":
|
||||
return t("documents.artifactTypeImage", "Image");
|
||||
case "video":
|
||||
return t("documents.artifactTypeVideo", "Video");
|
||||
case "audio":
|
||||
return t("documents.artifactTypeAudio", "Audio");
|
||||
case "document":
|
||||
return t("documents.artifactTypeDocument", "Document");
|
||||
case "other":
|
||||
return t("documents.artifactTypeOther", "Other");
|
||||
}
|
||||
}
|
||||
|
||||
function ArtifactCard({ artifact, projectId, onOpenTask }: ArtifactCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
|
||||
const typeLabel = getArtifactTypeLabel(t, artifact.type);
|
||||
const preview = artifact.content ? getContentPreview(artifact.content, 320) : artifact.description;
|
||||
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
|
||||
|
||||
const media = (() => {
|
||||
switch (artifact.type) {
|
||||
case "image":
|
||||
return <img className="documents-artifact-media" src={mediaUrl} alt={title} loading="lazy" />;
|
||||
case "video":
|
||||
return <video className="documents-artifact-media" controls src={mediaUrl} aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />;
|
||||
case "audio":
|
||||
return <audio className="documents-artifact-audio" controls src={mediaUrl} aria-label={t("documents.artifactAudioLabel", "Audio artifact: {{title}}", { title })} />;
|
||||
case "document":
|
||||
return (
|
||||
<div className="documents-artifact-document" data-testid="artifact-document-preview">
|
||||
<FileText size={16} />
|
||||
<p>{preview || t("documents.noArtifactPreview", "No preview available.")}</p>
|
||||
</div>
|
||||
);
|
||||
case "other":
|
||||
return (
|
||||
<a className="documents-artifact-generic" href={mediaUrl} target="_blank" rel="noreferrer" data-testid="artifact-other-link">
|
||||
<Package size={16} />
|
||||
{t("documents.openArtifactMedia", "Open artifact media")}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<article className="document-card documents-artifact-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
||||
<div className="documents-artifact-preview">
|
||||
{media}
|
||||
</div>
|
||||
<div className="documents-artifact-body">
|
||||
<div className="documents-artifact-header">
|
||||
<span className="documents-artifact-type-badge">{typeLabel}</span>
|
||||
<span className="documents-artifact-author">{artifact.authorId}</span>
|
||||
</div>
|
||||
<h3 className="documents-artifact-title">{title}</h3>
|
||||
{artifact.description && <p className="documents-artifact-description">{artifact.description}</p>}
|
||||
<div className="documents-artifact-meta">
|
||||
<span>{formatTimestamp(artifact.createdAt)}</span>
|
||||
{artifact.sizeBytes !== undefined && <span>{formatFileSize(artifact.sizeBytes)}</span>}
|
||||
</div>
|
||||
{artifact.taskId && (
|
||||
<button
|
||||
className="documents-group-task-link documents-artifact-task-link"
|
||||
onClick={() => onOpenTask(artifact.taskId as string)}
|
||||
aria-label={t("documents.openTaskAria", "Open task {{taskId}}: {{title}}", { taskId: artifact.taskId, title: artifact.taskTitle || t("documents.untitled", "Untitled") })}
|
||||
>
|
||||
{t("documents.openTask", "Open task")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelectionToTask }: DocumentsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
|
||||
@@ -200,6 +284,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection;
|
||||
|
||||
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
|
||||
const artifactSearchQuery = activeTab === "artifacts" ? searchQuery.trim() : "";
|
||||
|
||||
const {
|
||||
documents,
|
||||
@@ -219,6 +304,16 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
refresh: refreshProjectFiles,
|
||||
} = useProjectMarkdownFiles(projectId, { showHidden: showHiddenProjectFiles });
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
loading: artifactsLoading,
|
||||
error: artifactsError,
|
||||
refresh: refreshArtifacts,
|
||||
} = useArtifacts({
|
||||
projectId,
|
||||
searchQuery: artifactSearchQuery || undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateMobile = () => {
|
||||
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
||||
@@ -245,7 +340,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTabSetRef.current || documentsLoading || projectFilesLoading) {
|
||||
if (initialTabSetRef.current || documentsLoading || projectFilesLoading || artifactsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -253,10 +348,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
setActiveTab("project");
|
||||
} else if (documents.length > 0) {
|
||||
setActiveTab("tasks");
|
||||
} else if (artifacts.length > 0) {
|
||||
setActiveTab("artifacts");
|
||||
}
|
||||
|
||||
initialTabSetRef.current = true;
|
||||
}, [documents.length, documentsLoading, projectFiles.length, projectFilesLoading]);
|
||||
}, [artifacts.length, artifactsLoading, documents.length, documentsLoading, projectFiles.length, projectFilesLoading]);
|
||||
|
||||
const groupedDocuments = useMemo(() => {
|
||||
const groups = new Map<string, TaskDocumentWithTask[]>();
|
||||
@@ -373,17 +470,21 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
});
|
||||
}, []);
|
||||
|
||||
const activeError = activeTab === "project" ? projectFilesError : documentsError;
|
||||
const activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError;
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
if (activeTab === "project") {
|
||||
await refreshProjectFiles();
|
||||
return;
|
||||
}
|
||||
await refreshDocuments();
|
||||
}, [activeTab, refreshProjectFiles, refreshDocuments]);
|
||||
if (activeTab === "tasks") {
|
||||
await refreshDocuments();
|
||||
return;
|
||||
}
|
||||
await refreshArtifacts();
|
||||
}, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]);
|
||||
|
||||
const activeCount = activeTab === "project" ? filteredProjectFiles.length : documents.length;
|
||||
const activeCount = activeTab === "project" ? filteredProjectFiles.length : activeTab === "tasks" ? documents.length : artifacts.length;
|
||||
const selectionPopover = selectedFile && onSendSelectionToTask && activeProjectSelection ? (
|
||||
<SelectionCommentPopover
|
||||
selectedText={activeProjectSelection.selectedText}
|
||||
@@ -396,7 +497,9 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
|
||||
const searchPlaceholder = activeTab === "project"
|
||||
? t("documents.searchProjectFiles", "Search project markdown files…")
|
||||
: t("documents.searchTaskDocuments", "Search task documents…");
|
||||
: activeTab === "tasks"
|
||||
? t("documents.searchTaskDocuments", "Search task documents…")
|
||||
: t("documents.searchArtifacts", "Search artifacts…");
|
||||
|
||||
return (
|
||||
<div className="documents-view">
|
||||
@@ -433,6 +536,20 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
{t("documents.taskDocumentsTab", "Task Documents")}
|
||||
<span className="documents-tab-count">{groupedDocuments.length}</span>
|
||||
</button>
|
||||
{/*
|
||||
FNXC:ArtifactRegistry 2026-06-21-04:46:
|
||||
The Documents navigation has one canonical Artifacts tab so media produced by any agent is discoverable without adding another dashboard destination.
|
||||
*/}
|
||||
<button
|
||||
className={`btn documents-tab${activeTab === "artifacts" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "artifacts"}
|
||||
aria-label={t("documents.showArtifacts", "Show artifacts")}
|
||||
onClick={() => handleTabChange("artifacts")}
|
||||
>
|
||||
{t("documents.artifactsTab", "Artifacts")}
|
||||
<span className="documents-tab-count">{artifacts.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "project" && (
|
||||
@@ -474,7 +591,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
<div className="documents-view-content">
|
||||
{activeError ? (
|
||||
<div className="documents-view-error">
|
||||
<p>{t("documents.failedToLoad", "Failed to load {{type}}: {{error}}", { type: activeTab === "project" ? t("documents.projectFiles", "project files") : t("documents.taskDocuments", "task documents"), error: activeError })}</p>
|
||||
<p>{t("documents.failedToLoad", "Failed to load {{type}}: {{error}}", { type: activeTab === "project" ? t("documents.projectFiles", "project files") : activeTab === "tasks" ? t("documents.taskDocuments", "task documents") : t("documents.artifacts", "artifacts"), error: activeError })}</p>
|
||||
<button className="btn btn-primary" onClick={() => void handleRetry()} aria-label={t("documents.retryLoading", "Retry loading documents")}>
|
||||
<RefreshCw size={16} />
|
||||
{t("documents.retry", "Retry")}
|
||||
@@ -575,6 +692,41 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : activeTab === "artifacts" ? (
|
||||
artifactsLoading && artifacts.length === 0 ? (
|
||||
<div className="documents-view-loading">
|
||||
<p>{t("documents.loadingArtifacts", "Loading artifacts…")}</p>
|
||||
</div>
|
||||
) : artifacts.length === 0 ? (
|
||||
<div className="documents-view-empty">
|
||||
{searchQuery.trim() ? (
|
||||
<p>{t("documents.noMatchArtifacts", "No artifacts match \"{{query}}\".", { query: searchQuery.trim() })}</p>
|
||||
) : (
|
||||
<>
|
||||
<FileText size={48} className="documents-view-empty-icon" />
|
||||
<p>{t("documents.noArtifacts", "No artifacts yet.")}</p>
|
||||
<p className="documents-view-empty-hint">
|
||||
{t("documents.artifactsCreatedBy", "Artifacts are created by agents, users, and system tools.")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-06-21-04:46:
|
||||
The gallery must render all artifact media classes in one responsive surface: images, video, audio, inline documents, and generic file links keep their task and author context visible.
|
||||
*/
|
||||
<div className={`documents-artifact-gallery${isMobile ? " documents-artifact-gallery--mobile" : ""}`}>
|
||||
{artifacts.map((artifact) => (
|
||||
<ArtifactCard
|
||||
key={artifact.id}
|
||||
artifact={artifact}
|
||||
projectId={projectId}
|
||||
onOpenTask={handleOpenTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : documentsLoading && documents.length === 0 ? (
|
||||
<div className="documents-view-loading">
|
||||
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type { TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import { DocumentsView } from "../DocumentsView";
|
||||
import { fetchTaskDetail, fetchWorkspaceFileContent } from "../../api";
|
||||
import { useArtifacts } from "../../hooks/useArtifacts";
|
||||
import { useDocuments } from "../../hooks/useDocuments";
|
||||
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
|
||||
|
||||
@@ -11,17 +12,24 @@ vi.mock("../../api", () => ({
|
||||
fetchAllDocuments: vi.fn(),
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
fetchArtifacts: vi.fn(),
|
||||
artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDocuments", () => ({
|
||||
useDocuments: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useArtifacts", () => ({
|
||||
useArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useProjectMarkdownFiles", () => ({
|
||||
useProjectMarkdownFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseDocuments = vi.mocked(useDocuments);
|
||||
const mockUseArtifacts = vi.mocked(useArtifacts);
|
||||
const mockUseProjectMarkdownFiles = vi.mocked(useProjectMarkdownFiles);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
|
||||
@@ -96,6 +104,69 @@ const mockHiddenProjectFile = {
|
||||
mtime: "2026-04-19T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const mockArtifacts: ArtifactWithTask[] = [
|
||||
{
|
||||
id: "artifact-image",
|
||||
type: "image",
|
||||
title: "Image artifact",
|
||||
description: "Rendered image",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 128,
|
||||
uri: "artifacts/image.png",
|
||||
authorId: "agent-image",
|
||||
authorType: "agent",
|
||||
taskId: "KB-001",
|
||||
taskTitle: "Alpha task",
|
||||
createdAt: "2026-04-19T12:00:00.000Z",
|
||||
updatedAt: "2026-04-19T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "artifact-video",
|
||||
type: "video",
|
||||
title: "Video artifact",
|
||||
mimeType: "video/mp4",
|
||||
uri: "artifacts/video.mp4",
|
||||
authorId: "agent-video",
|
||||
authorType: "agent",
|
||||
createdAt: "2026-04-19T11:00:00.000Z",
|
||||
updatedAt: "2026-04-19T11:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "artifact-audio",
|
||||
type: "audio",
|
||||
title: "Audio artifact",
|
||||
mimeType: "audio/mpeg",
|
||||
uri: "artifacts/audio.mp3",
|
||||
authorId: "agent-audio",
|
||||
authorType: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "artifact-document",
|
||||
type: "document",
|
||||
title: "Document artifact",
|
||||
content: "Inline document preview",
|
||||
mimeType: "text/markdown",
|
||||
authorId: "agent-doc",
|
||||
authorType: "agent",
|
||||
createdAt: "2026-04-19T09:00:00.000Z",
|
||||
updatedAt: "2026-04-19T09:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "artifact-other",
|
||||
type: "other",
|
||||
title: "Other artifact",
|
||||
description: "Generic binary",
|
||||
mimeType: "application/octet-stream",
|
||||
uri: "artifacts/data.bin",
|
||||
authorId: "agent-other",
|
||||
authorType: "agent",
|
||||
createdAt: "2026-04-19T08:00:00.000Z",
|
||||
updatedAt: "2026-04-19T08:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
function setupHookDefaults(): void {
|
||||
mockUseDocuments.mockReturnValue({
|
||||
documents: mockTaskDocuments,
|
||||
@@ -111,6 +182,13 @@ function setupHookDefaults(): void {
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
}
|
||||
|
||||
describe("DocumentsView", () => {
|
||||
@@ -195,6 +273,89 @@ describe("DocumentsView", () => {
|
||||
expect(screen.queryByRole("button", { name: "Open README.md" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders artifacts tab counts and all media card paths", async () => {
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: mockArtifacts,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
const artifactsTab = screen.getByRole("tab", { name: /show artifacts/i });
|
||||
expect(artifactsTab).toHaveTextContent("5");
|
||||
expect(screen.getByRole("tab", { name: /show project markdown files/i })).toHaveTextContent("2");
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveTextContent("2");
|
||||
|
||||
fireEvent.click(artifactsTab);
|
||||
|
||||
expect(screen.getByRole("tab", { name: /show artifacts/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
|
||||
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");
|
||||
expect(screen.getByTestId("artifact-other-link")).toHaveAttribute("href", "/api/artifacts/artifact-other/media");
|
||||
expect(screen.getByText("agent-image")).toBeInTheDocument();
|
||||
expect(screen.getByText("Image")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /open task KB-001/i }));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTaskDetail).toHaveBeenCalledWith("KB-001", undefined);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith({ id: "KB-001" });
|
||||
});
|
||||
expect(screen.getAllByRole("button", { name: /open task/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders artifacts empty loading error retry and mobile gallery states", async () => {
|
||||
const artifactRefresh = vi.fn().mockResolvedValue(undefined);
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: artifactRefresh,
|
||||
});
|
||||
|
||||
const { rerender, container } = render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
expect(screen.getByText("No artifacts yet.")).toBeInTheDocument();
|
||||
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
refresh: artifactRefresh,
|
||||
});
|
||||
rerender(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
expect(screen.getByText("Loading artifacts…")).toBeInTheDocument();
|
||||
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: [],
|
||||
loading: false,
|
||||
error: "artifact boom",
|
||||
refresh: artifactRefresh,
|
||||
});
|
||||
rerender(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
expect(screen.getByText(/failed to load artifacts/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry loading documents/i }));
|
||||
await waitFor(() => expect(artifactRefresh).toHaveBeenCalledTimes(1));
|
||||
|
||||
window.innerWidth = 600;
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: mockArtifacts,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: artifactRefresh,
|
||||
});
|
||||
rerender(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
expect(container.querySelector(".documents-artifact-gallery--mobile")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking project file shows content", async () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
|
||||
136
packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts
Normal file
136
packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import type { ArtifactWithTask } from "@fusion/core";
|
||||
import { fetchArtifacts } from "../../api";
|
||||
import { useArtifacts } from "../useArtifacts";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchArtifacts = vi.mocked(fetchArtifacts);
|
||||
|
||||
const mockArtifacts: ArtifactWithTask[] = [
|
||||
{
|
||||
id: "artifact-1",
|
||||
type: "image",
|
||||
title: "Screenshot",
|
||||
authorId: "agent-1",
|
||||
authorType: "agent",
|
||||
taskId: "FN-1",
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
describe("useArtifacts", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
window.localStorage.clear();
|
||||
mockFetchArtifacts.mockResolvedValue(mockArtifacts);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("loads artifacts on initial mount", async () => {
|
||||
const { result } = renderHook(() => useArtifacts({ projectId: "project-1" }));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.artifacts).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.artifacts).toEqual(mockArtifacts);
|
||||
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, "project-1");
|
||||
});
|
||||
|
||||
it("propagates filter parameters to fetchArtifacts", async () => {
|
||||
renderHook(() => useArtifacts({
|
||||
projectId: "project-2",
|
||||
type: "video",
|
||||
authorId: "agent-video",
|
||||
taskId: "FN-2",
|
||||
searchQuery: "demo",
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(mockFetchArtifacts).toHaveBeenCalledWith({
|
||||
type: "video",
|
||||
authorId: "agent-video",
|
||||
taskId: "FN-2",
|
||||
q: "demo",
|
||||
}, "project-2");
|
||||
});
|
||||
|
||||
it("debounces search query changes", async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ searchQuery }) => useArtifacts({ projectId: "project-3", searchQuery }),
|
||||
{ initialProps: { searchQuery: undefined as string | undefined } },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
mockFetchArtifacts.mockClear();
|
||||
rerender({ searchQuery: "alpha" });
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(299);
|
||||
});
|
||||
expect(mockFetchArtifacts).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: "alpha" }, "project-3");
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces errors without clearing existing artifacts", async () => {
|
||||
mockFetchArtifacts.mockResolvedValueOnce(mockArtifacts);
|
||||
const { result } = renderHook(() => useArtifacts({ projectId: "project-4" }));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts));
|
||||
|
||||
mockFetchArtifacts.mockRejectedValueOnce(new Error("Artifacts failed"));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Artifacts failed");
|
||||
expect(result.current.artifacts).toEqual(mockArtifacts);
|
||||
});
|
||||
|
||||
it("refreshes artifacts on demand", async () => {
|
||||
const { result } = renderHook(() => useArtifacts({ projectId: "project-5" }));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
mockFetchArtifacts.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(mockFetchArtifacts).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, "project-5");
|
||||
});
|
||||
});
|
||||
145
packages/dashboard/app/hooks/useArtifacts.ts
Normal file
145
packages/dashboard/app/hooks/useArtifacts.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { ArtifactType, ArtifactWithTask } from "@fusion/core";
|
||||
import { fetchArtifacts } from "../api";
|
||||
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
||||
|
||||
export interface UseArtifactsResult {
|
||||
/** List of artifacts across agents and tasks */
|
||||
artifacts: ArtifactWithTask[];
|
||||
/** Loading state - true only for initial fetch, false during refresh/search */
|
||||
loading: boolean;
|
||||
/** Error message if artifact fetch failed */
|
||||
error: string | null;
|
||||
/** Refresh artifacts from the server */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ArtifactRegistry 2026-06-21-04:46:
|
||||
* The Documents Artifacts tab lists registry entries created by any agent, user, or system actor. Mirror the documents SWR pattern so cross-agent artifact search revalidates in the background without hiding the existing gallery during debounce or manual refresh.
|
||||
*/
|
||||
export function useArtifacts(options?: {
|
||||
/** Project ID for project-scoped fetching */
|
||||
projectId?: string;
|
||||
/** Filter artifacts by media type */
|
||||
type?: ArtifactType;
|
||||
/** Filter artifacts by author id */
|
||||
authorId?: string;
|
||||
/** Filter artifacts by parent task id */
|
||||
taskId?: string;
|
||||
/** Search query for artifact title/description */
|
||||
searchQuery?: string;
|
||||
}): UseArtifactsResult {
|
||||
const { projectId, type, authorId, taskId, searchQuery } = options ?? {};
|
||||
const filterKey = JSON.stringify({ type: type ?? null, authorId: authorId ?? null, taskId: taskId ?? null });
|
||||
const cacheKey = projectId ? `${SWR_CACHE_KEYS.ARTIFACTS_PREFIX}${projectId}:${filterKey}` : null;
|
||||
const [artifacts, setArtifacts] = useState<ArtifactWithTask[]>(() => {
|
||||
if (!cacheKey) {
|
||||
return [];
|
||||
}
|
||||
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
||||
return Array.isArray(cached) ? cached : [];
|
||||
});
|
||||
const [loading, setLoading] = useState(() => artifacts.length === 0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const initialLoadCompleteRef = useRef(artifacts.length > 0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
|
||||
const requestController = new AbortController();
|
||||
abortRef.current = requestController;
|
||||
|
||||
const isInitial = !initialLoadCompleteRef.current;
|
||||
if (isInitial) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const fetched = await fetchArtifacts({
|
||||
type,
|
||||
authorId,
|
||||
taskId,
|
||||
q: searchQuery,
|
||||
}, projectId);
|
||||
|
||||
if (requestController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setArtifacts(fetched);
|
||||
if (cacheKey) {
|
||||
const cachedPayload = fetched.length > 500 ? fetched.slice(0, 500) : fetched;
|
||||
writeCache(cacheKey, cachedPayload, { maxBytes: 500_000 });
|
||||
}
|
||||
initialLoadCompleteRef.current = true;
|
||||
} catch (err) {
|
||||
if (requestController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!requestController.signal.aborted && isInitial) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [authorId, cacheKey, projectId, searchQuery, taskId, type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey) {
|
||||
initialLoadCompleteRef.current = false;
|
||||
setArtifacts([]);
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
||||
if (Array.isArray(cached)) {
|
||||
setArtifacts(cached);
|
||||
initialLoadCompleteRef.current = true;
|
||||
setLoading(false);
|
||||
} else {
|
||||
initialLoadCompleteRef.current = false;
|
||||
setArtifacts([]);
|
||||
setLoading(true);
|
||||
}
|
||||
}, [cacheKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void refresh();
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
|
||||
return () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
artifacts,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const SWR_CACHE_KEYS = {
|
||||
AGENTS: "kb-dashboard-agents-cache",
|
||||
AGENT_STATS: "kb-dashboard-agent-stats-cache",
|
||||
DOCUMENTS_PREFIX: "kb-dashboard-documents-cache:",
|
||||
ARTIFACTS_PREFIX: "kb-dashboard-artifacts-cache:",
|
||||
TODO_LISTS_PREFIX: "kb-dashboard-todo-lists-cache:",
|
||||
CHAT_ROOMS: "kb-dashboard-chat-rooms-cache",
|
||||
CHAT_SESSIONS_PREFIX: "kb-dashboard-chat-sessions-cache:",
|
||||
|
||||
182
packages/dashboard/src/routes/__tests__/artifacts-routes.test.ts
Normal file
182
packages/dashboard/src/routes/__tests__/artifacts-routes.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { Artifact, ArtifactWithTask, TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function makeRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "fusion-artifacts-routes-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function makeArtifact(overrides: Partial<Artifact> = {}): Artifact {
|
||||
return {
|
||||
id: "artifact-1",
|
||||
type: "image",
|
||||
title: "Screenshot",
|
||||
mimeType: "image/png",
|
||||
uri: "artifacts/screenshot.png",
|
||||
authorId: "agent-1",
|
||||
authorType: "agent",
|
||||
taskId: "FN-1",
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeApp(store: Partial<TaskStore>) {
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store as TaskStore));
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("artifacts routes", () => {
|
||||
it("lists artifacts with parsed filters and clamped pagination", async () => {
|
||||
const artifact: ArtifactWithTask = { ...makeArtifact(), taskTitle: "Task" };
|
||||
const listArtifacts = vi.fn().mockResolvedValue([artifact]);
|
||||
const app = makeApp({
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
listArtifacts,
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/artifacts?type=image&authorId=agent-1&taskId=FN-1&q=screen&limit=5000&offset=2");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([artifact]);
|
||||
expect(listArtifacts).toHaveBeenCalledWith({
|
||||
type: "image",
|
||||
authorId: "agent-1",
|
||||
taskId: "FN-1",
|
||||
search: "screen",
|
||||
limit: 1000,
|
||||
offset: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/api/artifacts?type=bogus", "type"],
|
||||
["/api/artifacts?limit=0", "limit"],
|
||||
["/api/artifacts?limit=abc", "limit"],
|
||||
["/api/artifacts?offset=-1", "offset"],
|
||||
["/api/artifacts?offset=abc", "offset"],
|
||||
])("rejects invalid list query %s", async (path, expectedMessage) => {
|
||||
const listArtifacts = vi.fn();
|
||||
const app = makeApp({
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
listArtifacts,
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "GET", path);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toContain(expectedMessage);
|
||||
expect(listArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("streams task-scoped artifact media with its content type", async () => {
|
||||
const root = await makeRoot();
|
||||
const taskDir = join(root, ".fusion", "tasks", "FN-1");
|
||||
await mkdir(join(taskDir, "artifacts"), { recursive: true });
|
||||
await writeFile(join(taskDir, "artifacts", "screenshot.png"), Buffer.from("image-bytes"));
|
||||
|
||||
const artifact = makeArtifact();
|
||||
const app = makeApp({
|
||||
getRootDir: vi.fn(() => root),
|
||||
getTaskDir: vi.fn(() => taskDir),
|
||||
getFusionDir: vi.fn(() => join(root, ".fusion")),
|
||||
getArtifact: vi.fn().mockResolvedValue(artifact),
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/artifacts/artifact-1/media");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("image/png");
|
||||
expect(res.body).toBe("image-bytes");
|
||||
});
|
||||
|
||||
it("streams task-less registry artifact media from the fusion artifacts directory", async () => {
|
||||
const root = await makeRoot();
|
||||
const fusionDir = join(root, ".fusion");
|
||||
await mkdir(join(fusionDir, "artifacts"), { recursive: true });
|
||||
await writeFile(join(fusionDir, "artifacts", "registry.bin"), Buffer.from("registry-bytes"));
|
||||
|
||||
const artifact = makeArtifact({ taskId: undefined, uri: "artifacts/registry.bin", mimeType: "application/octet-stream" });
|
||||
const app = makeApp({
|
||||
getRootDir: vi.fn(() => root),
|
||||
getTaskDir: vi.fn(() => join(root, ".fusion", "tasks", "FN-1")),
|
||||
getFusionDir: vi.fn(() => fusionDir),
|
||||
getArtifact: vi.fn().mockResolvedValue(artifact),
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/artifacts/artifact-1/media");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("application/octet-stream");
|
||||
expect(res.body).toBe("registry-bytes");
|
||||
});
|
||||
|
||||
it("returns inline text artifact content when no uri exists", async () => {
|
||||
const artifact = makeArtifact({ uri: undefined, content: "inline text", mimeType: "text/plain" });
|
||||
const app = makeApp({
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getTaskDir: vi.fn(() => process.cwd()),
|
||||
getFusionDir: vi.fn(() => process.cwd()),
|
||||
getArtifact: vi.fn().mockResolvedValue(artifact),
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/artifacts/artifact-1/media");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toContain("text/plain");
|
||||
expect(res.body).toBe("inline text");
|
||||
});
|
||||
|
||||
it("returns 404 for missing artifact or missing file", async () => {
|
||||
const root = await makeRoot();
|
||||
const taskDir = join(root, ".fusion", "tasks", "FN-1");
|
||||
const missingApp = makeApp({
|
||||
getRootDir: vi.fn(() => root),
|
||||
getArtifact: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
expect((await REQUEST(missingApp, "GET", "/api/artifacts/missing/media")).status).toBe(404);
|
||||
|
||||
const missingFileApp = makeApp({
|
||||
getRootDir: vi.fn(() => root),
|
||||
getTaskDir: vi.fn(() => taskDir),
|
||||
getFusionDir: vi.fn(() => join(root, ".fusion")),
|
||||
getArtifact: vi.fn().mockResolvedValue(makeArtifact()),
|
||||
});
|
||||
|
||||
expect((await REQUEST(missingFileApp, "GET", "/api/artifacts/artifact-1/media")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects artifact uri path traversal before streaming", async () => {
|
||||
const root = await makeRoot();
|
||||
const taskDir = join(root, ".fusion", "tasks", "FN-1");
|
||||
const app = makeApp({
|
||||
getRootDir: vi.fn(() => root),
|
||||
getTaskDir: vi.fn(() => taskDir),
|
||||
getFusionDir: vi.fn(() => join(root, ".fusion")),
|
||||
getArtifact: vi.fn().mockResolvedValue(makeArtifact({ uri: "artifacts/../secret.txt" })),
|
||||
});
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/artifacts/artifact-1/media");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(JSON.stringify(res.body)).toContain("Invalid artifact media path");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { resolve, sep } from "node:path";
|
||||
import type {
|
||||
TaskStore,
|
||||
Task,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
DuplicateCandidate,
|
||||
DuplicateMatch,
|
||||
RunAuditEvent,
|
||||
ArtifactType,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
COLUMNS,
|
||||
@@ -56,6 +58,25 @@ const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Pla
|
||||
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
|
||||
const ARTIFACT_TYPES = new Set<ArtifactType>(["document", "image", "video", "audio", "other"]);
|
||||
|
||||
function isArtifactType(value: string): value is ArtifactType {
|
||||
return ARTIFACT_TYPES.has(value as ArtifactType);
|
||||
}
|
||||
|
||||
function resolveArtifactMediaPath(scopedStore: TaskStore, artifact: { taskId?: string; uri?: string }): string | null {
|
||||
if (!artifact.uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchorDir = artifact.taskId ? scopedStore.getTaskDir(artifact.taskId) : scopedStore.getFusionDir();
|
||||
const expectedArtifactsDir = resolve(anchorDir, "artifacts");
|
||||
const mediaPath = resolve(anchorDir, artifact.uri);
|
||||
if (mediaPath !== expectedArtifactsDir && !mediaPath.startsWith(`${expectedArtifactsDir}${sep}`)) {
|
||||
throw badRequest("Invalid artifact media path");
|
||||
}
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
interface AutoSyncOutcome {
|
||||
worktreePath: string | null;
|
||||
@@ -2674,6 +2695,110 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* FNXC:ArtifactRegistry 2026-06-21-04:46:
|
||||
* Documents view needs a cross-agent registry read surface for all artifact media classes. Keep query validation aligned with `/documents` so dashboard tabs share bounded pagination behavior while rejecting unknown artifact types before store access.
|
||||
*/
|
||||
router.get("/artifacts", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const {
|
||||
type: typeParam,
|
||||
authorId,
|
||||
taskId,
|
||||
q,
|
||||
limit: limitStr,
|
||||
offset: offsetStr,
|
||||
} = req.query as Record<string, string | undefined>;
|
||||
|
||||
let type: ArtifactType | undefined;
|
||||
if (typeParam !== undefined) {
|
||||
if (!isArtifactType(typeParam)) {
|
||||
throw badRequest("type must be one of: document, image, video, audio, other");
|
||||
}
|
||||
type = typeParam;
|
||||
}
|
||||
|
||||
let limit = 200;
|
||||
if (limitStr !== undefined) {
|
||||
const parsed = parseInt(limitStr, 10);
|
||||
if (isNaN(parsed) || parsed < 1) {
|
||||
throw badRequest("limit must be a positive integer");
|
||||
}
|
||||
limit = Math.min(parsed, 1000);
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
if (offsetStr !== undefined) {
|
||||
const parsed = parseInt(offsetStr, 10);
|
||||
if (isNaN(parsed) || parsed < 0) {
|
||||
throw badRequest("offset must be a non-negative integer");
|
||||
}
|
||||
offset = parsed;
|
||||
}
|
||||
|
||||
const artifacts = await scopedStore.listArtifacts({
|
||||
type,
|
||||
authorId,
|
||||
taskId,
|
||||
search: q,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
res.json(artifacts);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
throw new ApiError(500, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* FNXC:ArtifactRegistry 2026-06-21-04:46:
|
||||
* Media artifacts stream by registry id with the persisted MIME type so images, video, and audio render inline in the Documents gallery. Binary rows are anchored under either a task `artifacts/` directory or the task-less `.fusion/artifacts/` registry; inline text rows return their content directly because they intentionally have no file uri.
|
||||
*/
|
||||
router.get("/artifacts/:id/media", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const artifact = await scopedStore.getArtifact(req.params.id);
|
||||
if (!artifact) {
|
||||
throw notFound("Artifact not found");
|
||||
}
|
||||
|
||||
if (!artifact.uri) {
|
||||
if (artifact.content === undefined) {
|
||||
throw notFound("Artifact media not found");
|
||||
}
|
||||
res.setHeader("Content-Type", artifact.mimeType ?? "text/plain; charset=utf-8");
|
||||
res.send(artifact.content);
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaPath = resolveArtifactMediaPath(scopedStore, artifact);
|
||||
if (!mediaPath) {
|
||||
throw notFound("Artifact media not found");
|
||||
}
|
||||
|
||||
const stream = createReadStream(mediaPath);
|
||||
stream.on("error", () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(404).json({ error: "Artifact media not found" });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
res.setHeader("Content-Type", artifact.mimeType ?? "application/octet-stream");
|
||||
stream.pipe(res);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
throw new ApiError(500, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// GET /documents — List all documents across all tasks
|
||||
router.get("/documents", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -2193,7 +2193,27 @@
|
||||
"taskDocuments": "task documents",
|
||||
"taskDocumentsTab": "Task Documents",
|
||||
"title": "Documents",
|
||||
"untitled": "Untitled"
|
||||
"untitled": "Untitled",
|
||||
"artifacts": "artifacts",
|
||||
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
|
||||
"artifactsTab": "Artifacts",
|
||||
"artifactAudioLabel": "Audio artifact: {{title}}",
|
||||
"artifactCardLabel": "Artifact {{title}}",
|
||||
"artifactTypeAudio": "Audio",
|
||||
"artifactTypeDocument": "Document",
|
||||
"artifactTypeImage": "Image",
|
||||
"artifactTypeOther": "Other",
|
||||
"artifactTypeVideo": "Video",
|
||||
"artifactVideoLabel": "Video artifact: {{title}}",
|
||||
"loadingArtifacts": "Loading artifacts…",
|
||||
"noArtifactPreview": "No preview available.",
|
||||
"noArtifacts": "No artifacts yet.",
|
||||
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
|
||||
"openArtifactMedia": "Open artifact media",
|
||||
"openTaskAria": "Open task {{taskId}}: {{title}}",
|
||||
"searchArtifacts": "Search artifacts…",
|
||||
"showArtifacts": "Show artifacts",
|
||||
"untitledArtifact": "Untitled artifact"
|
||||
},
|
||||
"droidCli": {
|
||||
"active": "Active",
|
||||
|
||||
@@ -2193,7 +2193,27 @@
|
||||
"taskDocuments": "documentos de tareas",
|
||||
"taskDocumentsTab": "Documentos de tareas",
|
||||
"title": "Documentos",
|
||||
"untitled": "Sin título"
|
||||
"untitled": "Sin título",
|
||||
"artifacts": "artifacts",
|
||||
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
|
||||
"artifactsTab": "Artifacts",
|
||||
"artifactAudioLabel": "Audio artifact: {{title}}",
|
||||
"artifactCardLabel": "Artifact {{title}}",
|
||||
"artifactTypeAudio": "Audio",
|
||||
"artifactTypeDocument": "Document",
|
||||
"artifactTypeImage": "Image",
|
||||
"artifactTypeOther": "Other",
|
||||
"artifactTypeVideo": "Video",
|
||||
"artifactVideoLabel": "Video artifact: {{title}}",
|
||||
"loadingArtifacts": "Loading artifacts…",
|
||||
"noArtifactPreview": "No preview available.",
|
||||
"noArtifacts": "No artifacts yet.",
|
||||
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
|
||||
"openArtifactMedia": "Open artifact media",
|
||||
"openTaskAria": "Open task {{taskId}}: {{title}}",
|
||||
"searchArtifacts": "Search artifacts…",
|
||||
"showArtifacts": "Show artifacts",
|
||||
"untitledArtifact": "Untitled artifact"
|
||||
},
|
||||
"droidCli": {
|
||||
"active": "Activo",
|
||||
@@ -2292,7 +2312,10 @@
|
||||
"status": "Estado del ejecutor",
|
||||
"stuck": "Atascado",
|
||||
"temporary": "Temporal",
|
||||
"todoStatus": ""
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
"fileBrowser": {
|
||||
"back": "Volver a la lista de archivos",
|
||||
|
||||
@@ -2193,7 +2193,27 @@
|
||||
"taskDocuments": "documents des tâches",
|
||||
"taskDocumentsTab": "Documents des tâches",
|
||||
"title": "Documents",
|
||||
"untitled": "Sans titre"
|
||||
"untitled": "Sans titre",
|
||||
"artifacts": "artifacts",
|
||||
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
|
||||
"artifactsTab": "Artifacts",
|
||||
"artifactAudioLabel": "Audio artifact: {{title}}",
|
||||
"artifactCardLabel": "Artifact {{title}}",
|
||||
"artifactTypeAudio": "Audio",
|
||||
"artifactTypeDocument": "Document",
|
||||
"artifactTypeImage": "Image",
|
||||
"artifactTypeOther": "Other",
|
||||
"artifactTypeVideo": "Video",
|
||||
"artifactVideoLabel": "Video artifact: {{title}}",
|
||||
"loadingArtifacts": "Loading artifacts…",
|
||||
"noArtifactPreview": "No preview available.",
|
||||
"noArtifacts": "No artifacts yet.",
|
||||
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
|
||||
"openArtifactMedia": "Open artifact media",
|
||||
"openTaskAria": "Open task {{taskId}}: {{title}}",
|
||||
"searchArtifacts": "Search artifacts…",
|
||||
"showArtifacts": "Show artifacts",
|
||||
"untitledArtifact": "Untitled artifact"
|
||||
},
|
||||
"droidCli": {
|
||||
"active": "Actif",
|
||||
@@ -2292,7 +2312,10 @@
|
||||
"status": "État de l'exécuteur",
|
||||
"stuck": "Bloqué",
|
||||
"temporary": "Temporaire",
|
||||
"todoStatus": ""
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
"fileBrowser": {
|
||||
"back": "Retour à la liste des fichiers",
|
||||
|
||||
@@ -2193,7 +2193,27 @@
|
||||
"taskDocuments": "작업 문서",
|
||||
"taskDocumentsTab": "작업 문서",
|
||||
"title": "문서",
|
||||
"untitled": "제목 없음"
|
||||
"untitled": "제목 없음",
|
||||
"artifacts": "artifacts",
|
||||
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
|
||||
"artifactsTab": "Artifacts",
|
||||
"artifactAudioLabel": "Audio artifact: {{title}}",
|
||||
"artifactCardLabel": "Artifact {{title}}",
|
||||
"artifactTypeAudio": "Audio",
|
||||
"artifactTypeDocument": "Document",
|
||||
"artifactTypeImage": "Image",
|
||||
"artifactTypeOther": "Other",
|
||||
"artifactTypeVideo": "Video",
|
||||
"artifactVideoLabel": "Video artifact: {{title}}",
|
||||
"loadingArtifacts": "Loading artifacts…",
|
||||
"noArtifactPreview": "No preview available.",
|
||||
"noArtifacts": "No artifacts yet.",
|
||||
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
|
||||
"openArtifactMedia": "Open artifact media",
|
||||
"openTaskAria": "Open task {{taskId}}: {{title}}",
|
||||
"searchArtifacts": "Search artifacts…",
|
||||
"showArtifacts": "Show artifacts",
|
||||
"untitledArtifact": "Untitled artifact"
|
||||
},
|
||||
"droidCli": {
|
||||
"active": "활성",
|
||||
@@ -2292,7 +2312,10 @@
|
||||
"status": "실행기 상태",
|
||||
"stuck": "중단됨",
|
||||
"temporary": "임시",
|
||||
"todoStatus": ""
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
"fileBrowser": {
|
||||
"back": "파일 목록으로 돌아가기",
|
||||
|
||||
@@ -2193,7 +2193,27 @@
|
||||
"taskDocuments": "任务文档",
|
||||
"taskDocumentsTab": "任务文档",
|
||||
"title": "文档",
|
||||
"untitled": "未命名"
|
||||
"untitled": "未命名",
|
||||
"artifacts": "artifacts",
|
||||
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
|
||||
"artifactsTab": "Artifacts",
|
||||
"artifactAudioLabel": "Audio artifact: {{title}}",
|
||||
"artifactCardLabel": "Artifact {{title}}",
|
||||
"artifactTypeAudio": "Audio",
|
||||
"artifactTypeDocument": "Document",
|
||||
"artifactTypeImage": "Image",
|
||||
"artifactTypeOther": "Other",
|
||||
"artifactTypeVideo": "Video",
|
||||
"artifactVideoLabel": "Video artifact: {{title}}",
|
||||
"loadingArtifacts": "Loading artifacts…",
|
||||
"noArtifactPreview": "No preview available.",
|
||||
"noArtifacts": "No artifacts yet.",
|
||||
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
|
||||
"openArtifactMedia": "Open artifact media",
|
||||
"openTaskAria": "Open task {{taskId}}: {{title}}",
|
||||
"searchArtifacts": "Search artifacts…",
|
||||
"showArtifacts": "Show artifacts",
|
||||
"untitledArtifact": "Untitled artifact"
|
||||
},
|
||||
"droidCli": {
|
||||
"active": "活跃",
|
||||
@@ -2292,7 +2312,10 @@
|
||||
"status": "执行器状态",
|
||||
"stuck": "卡顿",
|
||||
"temporary": "临时",
|
||||
"todoStatus": ""
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
"fileBrowser": {
|
||||
"back": "返回文件列表",
|
||||
|
||||
@@ -2193,7 +2193,27 @@
|
||||
"taskDocuments": "工作文件",
|
||||
"taskDocumentsTab": "工作文件",
|
||||
"title": "文件",
|
||||
"untitled": "未命名"
|
||||
"untitled": "未命名",
|
||||
"artifacts": "artifacts",
|
||||
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
|
||||
"artifactsTab": "Artifacts",
|
||||
"artifactAudioLabel": "Audio artifact: {{title}}",
|
||||
"artifactCardLabel": "Artifact {{title}}",
|
||||
"artifactTypeAudio": "Audio",
|
||||
"artifactTypeDocument": "Document",
|
||||
"artifactTypeImage": "Image",
|
||||
"artifactTypeOther": "Other",
|
||||
"artifactTypeVideo": "Video",
|
||||
"artifactVideoLabel": "Video artifact: {{title}}",
|
||||
"loadingArtifacts": "Loading artifacts…",
|
||||
"noArtifactPreview": "No preview available.",
|
||||
"noArtifacts": "No artifacts yet.",
|
||||
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
|
||||
"openArtifactMedia": "Open artifact media",
|
||||
"openTaskAria": "Open task {{taskId}}: {{title}}",
|
||||
"searchArtifacts": "Search artifacts…",
|
||||
"showArtifacts": "Show artifacts",
|
||||
"untitledArtifact": "Untitled artifact"
|
||||
},
|
||||
"droidCli": {
|
||||
"active": "活躍",
|
||||
@@ -2292,7 +2312,10 @@
|
||||
"status": "執行器狀態",
|
||||
"stuck": "卡住",
|
||||
"temporary": "暫時",
|
||||
"todoStatus": ""
|
||||
"todoStatus": "",
|
||||
"engineControls": "Engine controls",
|
||||
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||
},
|
||||
"fileBrowser": {
|
||||
"back": "返回檔案清單",
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
"packages/core/src/central-core.ts": 3854,
|
||||
"packages/core/src/db.ts": 5840,
|
||||
"packages/core/src/mission-store.ts": 4382,
|
||||
"packages/core/src/store.ts": 16755,
|
||||
"packages/core/src/store.ts": 16776,
|
||||
"packages/core/src/types.ts": 7256,
|
||||
"packages/dashboard/app/App.tsx": 2298,
|
||||
"packages/dashboard/app/api/legacy.ts": 10568,
|
||||
"packages/dashboard/app/App.tsx": 2303,
|
||||
"packages/dashboard/app/api/legacy.ts": 10612,
|
||||
"packages/dashboard/app/components/AgentDetailView.tsx": 5400,
|
||||
"packages/dashboard/app/components/AgentsView.tsx": 2101,
|
||||
"packages/dashboard/app/components/ChatView.tsx": 3964,
|
||||
@@ -31,7 +31,7 @@
|
||||
"packages/dashboard/app/components/PlanningModeModal.tsx": 3319,
|
||||
"packages/dashboard/app/components/QuickChatFAB.tsx": 3560,
|
||||
"packages/dashboard/app/components/QuickEntryBox.tsx": 2207,
|
||||
"packages/dashboard/app/components/SettingsModal.tsx": 3254,
|
||||
"packages/dashboard/app/components/SettingsModal.tsx": 3251,
|
||||
"packages/dashboard/app/components/TaskCard.tsx": 2528,
|
||||
"packages/dashboard/app/components/TaskDetailModal.tsx": 4569,
|
||||
"packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4457,
|
||||
@@ -47,7 +47,7 @@
|
||||
"packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4526,
|
||||
"packages/dashboard/app/components/__tests__/SettingsModal.test.tsx": 5414,
|
||||
"packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121,
|
||||
"packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2366,
|
||||
"packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2407,
|
||||
"packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917,
|
||||
"packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2297,
|
||||
"packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5578,
|
||||
@@ -75,10 +75,10 @@
|
||||
"packages/dashboard/src/github.ts": 4178,
|
||||
"packages/dashboard/src/mission-routes.ts": 3948,
|
||||
"packages/dashboard/src/planning.ts": 2696,
|
||||
"packages/dashboard/src/routes.ts": 5251,
|
||||
"packages/dashboard/src/routes.ts": 5296,
|
||||
"packages/dashboard/src/routes/register-git-github.ts": 5637,
|
||||
"packages/dashboard/src/routes/register-settings-memory-routes.ts": 2421,
|
||||
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 3738,
|
||||
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 3863,
|
||||
"packages/dashboard/src/server.ts": 2378,
|
||||
"packages/engine/src/__tests__/executor-pause.test.ts": 2974,
|
||||
"packages/engine/src/__tests__/executor-prompt.test.ts": 2572,
|
||||
@@ -97,7 +97,7 @@
|
||||
"packages/engine/src/__tests__/triage.test.ts": 4511,
|
||||
"packages/engine/src/agent-heartbeat.ts": 4553,
|
||||
"packages/engine/src/agent-tools.ts": 3584,
|
||||
"packages/engine/src/executor.ts": 16018,
|
||||
"packages/engine/src/executor.ts": 16034,
|
||||
"packages/engine/src/merger.ts": 12643,
|
||||
"packages/engine/src/pi.ts": 2435,
|
||||
"packages/engine/src/project-engine.ts": 3663,
|
||||
|
||||
Reference in New Issue
Block a user