Compare commits

...

2 Commits

Author SHA1 Message Date
dev
94e89a4e97 Merge pull request 'GAZ-11: 实现 Artifact 预览导出与 Audit 组件' (#6) from agent/docker/gaz-11-artifact-audit into main
Reviewed-on: #6
2026-08-03 19:29:09 +00:00
Ubuntu
3103324fcf 实现 Artifact 与审计记录组件
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 19:24:31 +00:00
2 changed files with 147 additions and 0 deletions

View File

@ -0,0 +1,103 @@
import { ReactNode, useEffect, useState } from "react";
export type LoadState = "ready" | "loading" | "empty" | "error";
export interface ArtifactRecord { id: string; name: string; size: number; mimeType: string; sha256: string; createdAt: string; }
export interface ArtifactContent { blob: Blob; }
export interface ArtifactExportTarget { path: string; overwrite: boolean; }
export interface ArtifactSaveAdapter { save(artifact: ArtifactRecord, content: Blob, target: ArtifactExportTarget): Promise<void>; }
export interface ArtifactContentAdapter { load(id: string): Promise<ArtifactContent>; }
export const DEFAULT_PREVIEW_LIMIT = 5 * 1024 * 1024;
const TEXT_MIMES = new Set(["application/json", "application/xml", "application/yaml"]);
export function previewKind(mime: string): "text" | "image" | "unsupported" {
if (mime.startsWith("text/") || TEXT_MIMES.has(mime)) return "text";
if (mime.startsWith("image/") && mime !== "image/svg+xml") return "image";
return "unsupported";
}
export function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 ** 2).toFixed(1)} MB`;
}
function StatePanel({ state, error }: { state: LoadState; error?: string }) {
if (state === "loading") return <p role="status"></p>;
if (state === "empty") return <p role="status"></p>;
if (state === "error") return <p role="alert">{error ?? "未知错误"}</p>;
return null;
}
export function ArtifactList({ artifacts, state = "ready", error, selectedId, onSelect }: { artifacts: ArtifactRecord[]; state?: LoadState; error?: string; selectedId?: string; onSelect: (artifact: ArtifactRecord) => void }) {
if (state !== "ready") return <StatePanel state={state} error={error} />;
if (!artifacts.length) return <StatePanel state="empty" />;
return <section aria-label="Artifact 列表" className="records-table">{artifacts.map((item) => <button key={item.id} aria-pressed={selectedId === item.id} onClick={() => onSelect(item)}><span>{item.name}</span><small>{item.mimeType} · {formatBytes(item.size)}</small></button>)}</section>;
}
export function ArtifactMetadata({ artifact }: { artifact: ArtifactRecord }) {
return <dl aria-label="Artifact 元数据"><dt></dt><dd>{artifact.name}</dd><dt></dt><dd>{artifact.mimeType}</dd><dt></dt><dd>{formatBytes(artifact.size)}</dd><dt>SHA-256</dt><dd className="mono">{artifact.sha256}</dd><dt></dt><dd>{artifact.createdAt}</dd></dl>;
}
export function ArtifactPreview({ artifact, content, state = "ready", error, maxBytes = DEFAULT_PREVIEW_LIMIT }: { artifact: ArtifactRecord; content?: ArtifactContent; state?: LoadState; error?: string; maxBytes?: number }) {
const kind = previewKind(artifact.mimeType);
const [imageUrl, setImageUrl] = useState<string>();
useEffect(() => {
if (!content || kind !== "image") { setImageUrl(undefined); return; }
const url = URL.createObjectURL(content.blob);
setImageUrl(url);
return () => URL.revokeObjectURL(url);
}, [content, kind]);
if (state !== "ready") return <StatePanel state={state} error={error} />;
if (artifact.size > maxBytes) return <p role="status"> {formatBytes(maxBytes)}使</p>;
if (kind === "unsupported") return <p role="status"></p>;
if (!content) return <p role="status"></p>;
if (kind === "image") {
return imageUrl ? <img src={imageUrl} alt={`${artifact.name} 预览`} /> : <p role="status"></p>;
}
return <TextBlob blob={content.blob} />;
}
function TextBlob({ blob }: { blob: Blob }) {
const [text, setText] = useState<string>();
const [failed, setFailed] = useState(false);
useEffect(() => { let active = true; setText(undefined); setFailed(false); blob.text().then((value) => active && setText(value), () => active && setFailed(true)); return () => { active = false; }; }, [blob]);
if (failed) return <p role="alert"></p>;
return text === undefined ? <p role="status"></p> : <pre>{text}</pre>;
}
export function ExportArtifactDialog({ artifact, content, adapter, onClose }: { artifact: ArtifactRecord; content: Blob; adapter: ArtifactSaveAdapter; onClose: () => void }) {
const [path, setPath] = useState(artifact.name);
const [overwrite, setOverwrite] = useState(false);
const [status, setStatus] = useState<"idle" | "saving" | "error">("idle");
const save = async () => { setStatus("saving"); try { await adapter.save(artifact, content, { path, overwrite }); onClose(); } catch { setStatus("error"); } };
return <section role="dialog" aria-modal="true" aria-labelledby="export-title"><h2 id="export-title"> Artifact</h2><p></p><ArtifactMetadata artifact={artifact} /><label><input aria-label="目标路径" value={path} onChange={(e) => setPath(e.currentTarget.value)} /></label><label><input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.currentTarget.checked)} /></label>{status === "error" && <p role="alert"></p>}<button onClick={onClose}></button><button disabled={!path.trim() || status === "saving"} onClick={save}></button></section>;
}
const SECRET_PATTERN = /(token|cookie|password|passwd|secret|api[_-]?key|private[_-]?key|authorization)/i;
export interface RedactedValue { value: unknown; redacted: boolean; }
export function redactSecrets(value: unknown, key = ""): unknown {
if (SECRET_PATTERN.test(key)) return "[已脱敏]";
if (Array.isArray(value)) return value.map((item) => redactSecrets(item));
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([childKey, child]) => [childKey, redactSecrets(child, childKey)]));
return value;
}
export function ParameterSummary({ parameters, allowReveal = false, onReveal }: { parameters: Record<string, unknown>; allowReveal?: boolean; onReveal?: () => void }) {
const [revealed, setRevealed] = useState(false);
const shown = revealed ? parameters : redactSecrets(parameters);
return <div><pre aria-label="参数摘要">{JSON.stringify(shown, null, 2)}</pre>{allowReveal && !revealed && <button onClick={() => { setRevealed(true); onReveal?.(); }}></button>}</div>;
}
export interface AuditRecord { id: string; actor: string; target: string; operation: string; parameters: Record<string, unknown>; result: string; requestId: string; timestamp: string; }
export type AuditQueryIntent = { type: "filter"; field: "actor" | "target" | "operation" | "result"; value: string } | { type: "page"; cursor?: string; direction: "previous" | "next" };
export function RequestIdLink({ requestId, onOpen }: { requestId: string; onOpen?: (requestId: string) => void }) { return onOpen ? <button className="mono" onClick={() => onOpen(requestId)}>{requestId}</button> : <span className="mono">{requestId}</span>; }
export function AuditTable({ records, state = "ready", error, nextCursor, previousCursor, onQueryIntent, onSelect }: { records: AuditRecord[]; state?: LoadState; error?: string; nextCursor?: string; previousCursor?: string; onQueryIntent: (intent: AuditQueryIntent) => void; onSelect: (record: AuditRecord) => void }) {
if (state !== "ready") return <StatePanel state={state} error={error} />;
return <section><label><input aria-label="操作筛选" onChange={(e) => onQueryIntent({ type: "filter", field: "operation", value: e.currentTarget.value })} /></label>{records.length ? <table><thead><tr><th></th><th></th><th></th><th></th><th></th><th>Request ID</th></tr></thead><tbody>{records.map((record) => <tr key={record.id} onClick={() => onSelect(record)}><td>{record.timestamp}</td><td>{record.actor}</td><td>{record.target}</td><td>{record.operation}</td><td>{record.result}</td><td><RequestIdLink requestId={record.requestId} /></td></tr>)}</tbody></table> : <StatePanel state="empty" />}<footer><button disabled={!previousCursor} onClick={() => onQueryIntent({ type: "page", direction: "previous", cursor: previousCursor })}></button><button disabled={!nextCursor} onClick={() => onQueryIntent({ type: "page", direction: "next", cursor: nextCursor })}></button></footer></section>;
}
export function AuditDetail({ record, allowReveal, onReveal }: { record: AuditRecord; allowReveal?: boolean; onReveal?: () => void }) { return <article><h2></h2><dl><dt></dt><dd>{record.actor}</dd><dt></dt><dd>{record.target}</dd><dt></dt><dd>{record.operation}</dd><dt></dt><dd>{record.result}</dd><dt>Request ID</dt><dd><RequestIdLink requestId={record.requestId} /></dd><dt></dt><dd>{record.timestamp}</dd></dl><ParameterSummary parameters={record.parameters} allowReveal={allowReveal} onReveal={onReveal} /></article>; }
export function MemoryOnlyArtifactController({ adapter, artifact, children }: { adapter: ArtifactContentAdapter; artifact: ArtifactRecord; children: (state: { content?: ArtifactContent; state: LoadState; load: () => Promise<void> }) => ReactNode }) {
const [content, setContent] = useState<ArtifactContent>(); const [state, setState] = useState<LoadState>("empty");
const load = async () => { setState("loading"); try { setContent(await adapter.load(artifact.id)); setState("ready"); } catch { setState("error"); } };
return <>{children({ content, state, load })}</>;
}

View File

@ -0,0 +1,44 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ArtifactPreview, AuditTable, ExportArtifactDialog, ParameterSummary, previewKind, redactSecrets, type ArtifactRecord, type AuditRecord } from ".";
const artifact: ArtifactRecord = { id: "a1", name: "report.txt", size: 12, mimeType: "text/plain", sha256: "abc123", createdAt: "2026-08-03T10:00:00Z" };
const audit: AuditRecord = { id: "e1", actor: "analyst", target: "session/s1", operation: "artifact.export", parameters: { path: "/safe", token: "secret", nested: { password: "pw" } }, result: "success", requestId: "req-1", timestamp: "2026-08-03T10:00:00Z" };
describe("Artifact records", () => {
it("限制危险 MIME 和过大内容的内嵌预览", () => {
expect(previewKind("image/svg+xml")).toBe("unsupported");
const { rerender } = render(<ArtifactPreview artifact={{ ...artifact, mimeType: "application/x-executable" }} />);
expect(screen.getByText(/不支持安全内嵌/)).toBeInTheDocument();
rerender(<ArtifactPreview artifact={{ ...artifact, size: 11 }} maxBytes={10} />);
expect(screen.getByText(/文件过大/)).toBeInTheDocument();
});
it("只有用户确认后才调用保存 adapter并携带覆盖意图", async () => {
const save = vi.fn().mockResolvedValue(undefined); const close = vi.fn();
render(<ExportArtifactDialog artifact={artifact} content={new Blob(["hello"])} adapter={{ save }} onClose={close} />);
expect(save).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("目标路径"), { target: { value: "/exports/report.txt" } });
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: "确认导出" }));
await waitFor(() => expect(save).toHaveBeenCalledWith(artifact, expect.any(Blob), { path: "/exports/report.txt", overwrite: true }));
});
});
describe("Audit records", () => {
it("递归脱敏常见秘密字段,原始值需要显式动作", () => {
expect(redactSecrets(audit.parameters)).toEqual({ path: "/safe", token: "[已脱敏]", nested: { password: "[已脱敏]" } });
const reveal = vi.fn(); render(<ParameterSummary parameters={audit.parameters} allowReveal onReveal={reveal} />);
expect(screen.queryByText(/"secret"/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /显示原始敏感值/ }));
expect(screen.getByLabelText("参数摘要")).toHaveTextContent("secret"); expect(reveal).toHaveBeenCalledOnce();
});
it("筛选和游标分页只发出服务端查询意图", () => {
const intent = vi.fn(); render(<AuditTable records={[audit]} nextCursor="next-2" onQueryIntent={intent} onSelect={() => undefined} />);
fireEvent.change(screen.getByLabelText("操作筛选"), { target: { value: "export" } });
fireEvent.click(screen.getByRole("button", { name: "下一页" }));
expect(intent).toHaveBeenNthCalledWith(1, { type: "filter", field: "operation", value: "export" });
expect(intent).toHaveBeenNthCalledWith(2, { type: "page", direction: "next", cursor: "next-2" });
});
});