From 3103324fcf35fb075bdbc0cf851b31bf51e50666 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 3 Aug 2026 19:24:31 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Artifact=20=E4=B8=8E?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E8=AE=B0=E5=BD=95=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- src/features/records/index.tsx | 103 ++++++++++++++++++++++++++ src/features/records/records.test.tsx | 44 +++++++++++ 2 files changed, 147 insertions(+) create mode 100644 src/features/records/index.tsx create mode 100644 src/features/records/records.test.tsx diff --git a/src/features/records/index.tsx b/src/features/records/index.tsx new file mode 100644 index 0000000..0a15771 --- /dev/null +++ b/src/features/records/index.tsx @@ -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; } +export interface ArtifactContentAdapter { load(id: string): Promise; } + +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

正在加载…

; + if (state === "empty") return

暂无记录

; + if (state === "error") return

加载失败:{error ?? "未知错误"}

; + 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 ; + if (!artifacts.length) return ; + return
{artifacts.map((item) => )}
; +} + +export function ArtifactMetadata({ artifact }: { artifact: ArtifactRecord }) { + return
文件名
{artifact.name}
类型
{artifact.mimeType}
大小
{formatBytes(artifact.size)}
SHA-256
{artifact.sha256}
创建时间
{artifact.createdAt}
; +} + +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(); + 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 ; + if (artifact.size > maxBytes) return

文件过大,无法预览(上限 {formatBytes(maxBytes)})。可显式导出后使用受信任工具查看。

; + if (kind === "unsupported") return

不支持安全内嵌预览此格式。内容不会执行,可显式导出。

; + if (!content) return

尚未加载预览内容

; + if (kind === "image") { + return imageUrl ? {`${artifact.name} :

正在准备图片…

; + } + return ; +} + +function TextBlob({ blob }: { blob: Blob }) { + const [text, setText] = useState(); + 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

无法读取文本预览

; + return text === undefined ?

正在解码文本…

:
{text}
; +} + +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

导出 Artifact

仅此确认操作会将内存内容写入磁盘。

{status === "error" &&

导出失败,未确认文件已写入。

}
; +} + +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).map(([childKey, child]) => [childKey, redactSecrets(child, childKey)])); + return value; +} + +export function ParameterSummary({ parameters, allowReveal = false, onReveal }: { parameters: Record; allowReveal?: boolean; onReveal?: () => void }) { + const [revealed, setRevealed] = useState(false); + const shown = revealed ? parameters : redactSecrets(parameters); + return
{JSON.stringify(shown, null, 2)}
{allowReveal && !revealed && }
; +} + +export interface AuditRecord { id: string; actor: string; target: string; operation: string; parameters: Record; 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 ? : {requestId}; } +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 ; + return
{records.length ? {records.map((record) => onSelect(record)}>)}
时间操作者目标操作结果Request ID
{record.timestamp}{record.actor}{record.target}{record.operation}{record.result}
: }
; +} +export function AuditDetail({ record, allowReveal, onReveal }: { record: AuditRecord; allowReveal?: boolean; onReveal?: () => void }) { return

审计详情

操作者
{record.actor}
目标
{record.target}
操作
{record.operation}
结果
{record.result}
Request ID
时间
{record.timestamp}
; } + +export function MemoryOnlyArtifactController({ adapter, artifact, children }: { adapter: ArtifactContentAdapter; artifact: ArtifactRecord; children: (state: { content?: ArtifactContent; state: LoadState; load: () => Promise }) => ReactNode }) { + const [content, setContent] = useState(); const [state, setState] = useState("empty"); + const load = async () => { setState("loading"); try { setContent(await adapter.load(artifact.id)); setState("ready"); } catch { setState("error"); } }; + return <>{children({ content, state, load })}; +} diff --git a/src/features/records/records.test.tsx b/src/features/records/records.test.tsx new file mode 100644 index 0000000..e26bc96 --- /dev/null +++ b/src/features/records/records.test.tsx @@ -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(); + expect(screen.getByText(/不支持安全内嵌/)).toBeInTheDocument(); + rerender(); + expect(screen.getByText(/文件过大/)).toBeInTheDocument(); + }); + + it("只有用户确认后才调用保存 adapter,并携带覆盖意图", async () => { + const save = vi.fn().mockResolvedValue(undefined); const close = vi.fn(); + render(); + 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(); + 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( 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" }); + }); +});