feat(swads): add daily image report workflow

This commit is contained in:
Jeffrey Wu
2026-09-07 11:34:00 +08:00
parent f39f6ac881
commit 5c61371d7e
38 changed files with 2537 additions and 52 deletions
+14 -3
View File
@@ -13,6 +13,9 @@ import {
InMemorySessionRegistry,
PiAgentSessionFactory,
SkillCatalog,
SwadsGateway,
ReportStore,
REPORT_CSP,
safeErrorMessage,
type AgentSessionFactory,
} from "@agent-studio/harness";
@@ -25,7 +28,7 @@ const eventQuery = z.object({ afterSequence: z.coerce.number().int().nonnegative
function parse<T>(schema: z.ZodType<T>, value: unknown): T { return schema.parse(value); }
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog }
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog; reports: ReportStore }
export interface AppOptions { projectRoot?: string; runTimeoutMs?: number; approvalTimeoutMs?: number; heartbeatMs?: number; agentFactory?: AgentSessionFactory; onReady?: (services: AppServices) => void }
export async function buildApp(options: AppOptions = {}): Promise<FastifyInstance> {
@@ -35,6 +38,8 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
await mkdir(agentDir, { recursive: true, mode: 0o700 });
const gateway = new SwadsGateway();
const reports = new ReportStore(path.join(projectRoot, "workspace/reports"), path.join(projectRoot, ".agents/skills/swads-daily-report/assets/report.css"));
const events = new InMemoryEventStore(1_000);
const credentialVault = process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY
? new EncryptedFileCredentialVault(path.join(projectRoot, "workspace", "model-credentials.enc.json"), process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY)
@@ -46,8 +51,8 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
const skills = new SkillCatalog(projectRoot, agentDir);
await skills.reload();
const sessions = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, skills, undefined, options.approvalTimeoutMs ?? Number(process.env.APPROVAL_TIMEOUT_MS ?? 60_000), options.runTimeoutMs ?? Number(process.env.SESSION_RUN_TIMEOUT_MS ?? 120_000));
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals));
options.onReady?.({ sessions, events, attachments, models, skills });
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals, gateway, reports));
options.onReady?.({ sessions, events, attachments, models, skills, reports });
const app = Fastify({
genReqId: () => `req_${randomUUID()}`,
@@ -65,6 +70,12 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
return sendError(reply, request, 500, "INTERNAL_ERROR", "Internal server error");
});
app.get("/api/reports/:accountKey/:reportDate/:reportId/:format", async (request, reply) => {
const params = request.params as Record<string, string>;
const result = await reports.read({ accountKey: params.accountKey, reportDate: params.reportDate, reportId: params.reportId }, params.format);
const mime = { png: "image/png", html: "text/html; charset=utf-8", json: "application/json; charset=utf-8" };
return reply.header("content-type", mime[result.format]).header("content-disposition", `${result.format === "png" ? "inline" : "attachment"}; filename="swads-daily-report.${result.format}"`).header("x-content-type-options", "nosniff").header("content-security-policy", REPORT_CSP).header("cache-control", "private, no-store").send(result.bytes);
});
app.get("/health", async (request) => ({ ok: true, requestId: request.id }));
app.get("/api/skills", async (request) => ({ skills: skills.list(), requestId: request.id }));
app.post("/api/sessions", async (request, reply) => { const session = await sessions.create(); return reply.code(201).send({ sessionId: session.sessionId, requestId: request.id }); });
+11 -2
View File
@@ -1,9 +1,10 @@
import { mkdtemp, mkdir } from "node:fs/promises";
import { mkdtemp, mkdir, copyFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildApp, type AppServices } from "../src/app.js";
import type { AgentSessionFactory, AgentSessionPort } from "@agent-studio/harness";
import { sampleReport } from "../../../packages/harness/tests/report-fixture.js";
import type { FastifyInstance } from "fastify";
class ServerFakePort implements AgentSessionPort {
@@ -20,7 +21,7 @@ afterEach(async () => { while (apps.length) await apps.pop()?.close(); });
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "studio-server-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); await mkdir(path.join(root, "fixtures"));
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, services: services! };
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, root, services: services! };
}
async function createSession(app: FastifyInstance) { const response = await app.inject({ method: "POST", url: "/api/sessions" }); return (response.json() as { sessionId: string }).sessionId; }
async function configure(app: FastifyInstance, sessionId: string, supportsImages = true) { return app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake-model", apiKey: "sk-test-never-return", supportsImages } }); }
@@ -36,6 +37,14 @@ describe("Fastify API", () => {
it("supports approve and deny endpoints with conflict detection", async () => { const { app, services } = await fixture(); const sessionId = await createSession(app); const controller = new AbortController(); const pending = services.sessions.approvals.request({ sessionId, runId: "run_test", toolCallId: "tool_test", toolName: "save_report_draft", arguments: {}, riskLevel: "medium" }, controller.signal); const event = services.events.listAfter(sessionId).events.find((item) => item.type === "approval.required"); if (!event || event.type !== "approval.required") throw new Error("missing approval"); const approved = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/approve` }); expect(approved.statusCode).toBe(200); expect((await pending).status).toBe("approved"); const conflict = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/deny` }); expect(conflict.statusCode).toBe(409); });
it("accepts a valid multipart PNG and rejects SVG", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const accepted = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "image.png", "image/png", png) }); expect(accepted.statusCode).toBe(201); expect(accepted.json()).toMatchObject({ mediaType: "image/png", width: 1, status: "ready" }); const rejected = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "x.svg", "image/svg+xml", Buffer.from("<svg/>")) }); expect(rejected.statusCode).toBe(415); });
it("serves SSE events and heartbeat comments", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const address = await app.listen({ host: "127.0.0.1", port: 0 }); const controller = new AbortController(); const response = await fetch(`${address}/api/sessions/${sessionId}/events`, { signal: controller.signal }); expect(response.headers.get("content-type")).toContain("text/event-stream"); const reader = response.body!.getReader(); let text = ""; for (let count = 0; count < 4 && !text.includes(": heartbeat"); count++) { const chunk = await reader.read(); text += new TextDecoder().decode(chunk.value); } controller.abort(); expect(text).toContain("event: session.started"); expect(text).toContain(": heartbeat"); });
it("keeps reports downloadable after session deletion and guards artifact routes", async () => {
const { app, root, services } = await fixture(); const assets = path.join(root, ".agents/skills/swads-daily-report/assets"); await mkdir(assets, { recursive: true }); await copyFile(path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css"), path.join(assets, "report.css"));
const artifact = await services.reports.create(sampleReport(), new AbortController().signal); const session = await createSession(app); await app.inject({ method: "DELETE", url: `/api/sessions/${session}` });
for (const [format, url] of Object.entries(artifact.downloads)) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(200); expect(response.headers["content-disposition"]).toContain(format === "png" ? "inline" : "attachment"); expect(response.headers["x-content-type-options"]).toBe("nosniff"); expect(response.headers["content-security-policy"]).toContain("default-src 'none'"); expect(response.body).not.toContain(root); expect(response.headers["content-type"]).toContain(format === "png" ? "image/png" : format === "json" ? "application/json" : "text/html"); }
const base = artifact.downloads.json.slice(0, -4);
for (const url of [base + "secret", base.replace(artifact.reportId, "00000000-0000-4000-8000-000000000000") + "json", "/api/reports/%2e%2e/x/y/json", "/api/reports/", base + "%2e%2e%2fsecret"]) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain(root); }
}, 60_000);
});
function multipart(boundary: string, filename: string, mime: string, data: Buffer): Buffer { return Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); }
+13 -2
View File
@@ -3,15 +3,26 @@
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": { "dev": "vite --host 127.0.0.1", "typecheck": "tsc --noEmit", "build": "vite build" },
"scripts": {
"dev": "vite --host 127.0.0.1",
"typecheck": "tsc --noEmit",
"build": "vite build"
},
"dependencies": {
"@agent-studio/shared": "workspace:*",
"@assistant-ui/react": "0.15.17",
"@assistant-ui/react-markdown": "^0.14.14",
"@radix-ui/react-dialog": "^1.1.15",
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^4.1.13"
},
"devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "typescript": "^5.9.3", "vite": "^7.2.6" }
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
}
+37 -4
View File
@@ -15,7 +15,7 @@ export interface ClientState {
interface LocalAttachment extends ImageAttachment { objectUrl: string }
type Listener = () => void;
const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended", "user.message", "assistant.delta", "assistant.completed", "skill.requested", "skill.loaded", "tool.requested", "tool.completed", "tool.failed", "approval.required", "approval.resolved", "attachment.uploaded", "attachment.rejected", "model.configured", "model.validation_failed", "run.started", "run.cancelled", "run.completed", "run.failed"];
const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended", "user.message", "assistant.delta", "assistant.completed", "report.generated", "skill.requested", "skill.loaded", "tool.requested", "tool.completed", "tool.failed", "approval.required", "approval.resolved", "attachment.uploaded", "attachment.rejected", "model.configured", "model.validation_failed", "run.started", "run.cancelled", "run.completed", "run.failed"];
const modelStorageKey = "agent-studio-mini:model-connection:v1";
export class HarnessClientStore {
@@ -23,6 +23,8 @@ export class HarnessClientStore {
private readonly listeners = new Set<Listener>();
private readonly seen = new Set<string>();
private readonly attachments = new Map<string, LocalAttachment>();
private readonly reportImages = new Map<string, string | undefined>();
private generation = 0;
private source?: EventSource;
private lastSequence = 0;
private initializing: Promise<void> | undefined;
@@ -95,7 +97,13 @@ export class HarnessClientStore {
} else if (event.type === "assistant.delta") {
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => appendText(parts, event.payload.delta), { type: "running" }, event.runId);
} else if (event.type === "assistant.completed") {
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => parts, { type: "complete", reason: "stop" });
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => parts, { type: "complete", reason: "stop" }, event.runId);
} else if (event.type === "report.generated") {
const { artifact, assistantMessageId } = event.payload;
if (artifact.previewUrl) {
messages = updateAssistant(messages, assistantMessageId, event.timestamp, (parts) => parts, { type: "running" }, event.runId);
void this.loadReportImage(event);
}
} else if (event.type === "tool.requested") {
const id = assistantIdForRun(messages, event.runId);
messages = updateAssistant(messages, id, event.timestamp, (parts) => upsertToolCall(parts, event.payload.toolCallId, event.payload.toolName, asArgs(event.payload.arguments)), { type: "running" }, event.runId);
@@ -113,6 +121,28 @@ export class HarnessClientStore {
this.update({ events, messages, isRunning });
}
private async loadReportImage(event: Extract<HarnessEvent, { type: "report.generated" }>): Promise<void> {
const { artifact, assistantMessageId } = event.payload;
const url = artifact.previewUrl;
if (!url || this.reportImages.has(url)) return;
this.reportImages.set(url, undefined);
const generation = this.generation;
try {
const response = await fetch(url);
if (!response.ok || !response.headers.get("content-type")?.startsWith("image/png")) throw new Error("图片加载失败,可通过下载链接重试。");
const blob = await response.blob();
if (generation !== this.generation) return;
const image = URL.createObjectURL(blob);
this.reportImages.set(url, image);
const existing = this.state.messages.find((message) => message.id === assistantMessageId);
const status = this.state.isRunning ? existing?.status ?? { type: "running" as const } : { type: "complete" as const, reason: "stop" as const };
const messages = updateAssistant(this.state.messages, assistantMessageId, event.timestamp, (parts) => [...parts, { type: "image", image, filename: `SW Ads ${artifact.reportDate}` }], status, event.runId);
this.update({ messages });
} catch {
if (generation === this.generation) this.update({ error: "日报图片预览加载失败,可通过下载链接重试。" });
}
}
onNew = async (message: AppendMessage): Promise<void> => {
const sessionId = await this.requireSession();
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
@@ -125,6 +155,9 @@ export class HarnessClientStore {
async reset(): Promise<void> {
const old = this.state.sessionId;
this.generation++;
for (const image of this.reportImages.values()) if (image) URL.revokeObjectURL(image);
this.reportImages.clear();
this.source?.close();
if (old) await api(`/api/sessions/${old}`, { method: "DELETE" });
for (const item of this.attachments.values()) URL.revokeObjectURL(item.objectUrl);
@@ -177,9 +210,9 @@ export class HarnessClientStore {
type ContentParts = Exclude<ThreadMessageLike["content"], string>;
function updateAssistant(messages: ThreadMessageLike[], id: string, timestamp: string, change: (parts: ContentParts) => ContentParts, status: ThreadMessageLike["status"], runId?: string): ThreadMessageLike[] {
const index = messages.findIndex((item) => item.id === id);
const index = messages.findIndex((item) => item.id === id || (runId && item.role === "assistant" && item.metadata?.custom?.runId === runId));
if (index < 0) return [...messages, { id, role: "assistant", createdAt: new Date(timestamp), content: change([]), status, metadata: { custom: runId ? { runId } : {} } }];
return messages.map((item, current) => current === index ? { ...item, content: change(typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content), status } : item);
return messages.map((item, current) => current === index ? { ...item, id, content: change(typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content), status } : item);
}
function appendText(parts: ContentParts, delta: string): ContentParts { const last = parts.at(-1); return last?.type === "text" ? [...parts.slice(0, -1), { ...last, text: last.text + delta }] : [...parts, { type: "text", text: delta }]; }
function upsertToolCall(parts: ContentParts, toolCallId: string, toolName: string, args: Record<string, string | number | boolean | null>): ContentParts {
+5 -4
View File
@@ -1,3 +1,4 @@
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
import { useState } from "react";
import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import { ImagePlus, Send, Square, X } from "lucide-react";
@@ -7,14 +8,14 @@ import { useHarnessState } from "../assistant/HarnessContext";
const demoPrompt = "请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。";
function TextPart({ text }: { text: string }) { return <div className="message-text">{text}</div>; }
function ImagePart({ image, filename }: { image: string; filename?: string }) { return <ImagePreview src={image} name={filename ?? "图片"}/>; }
function TextPart() { return <MarkdownTextPrimitive className="message-text markdown" smooth={false} disallowedElements={["img"]}/>; }
function ImagePart({ image, filename }: { image: string; filename?: string }) { return <ImagePreview src={image} name={filename ?? "图片"} report={filename?.startsWith("SW Ads ") ?? false}/>; }
const partComponents = { Text: TextPart, Image: ImagePart, tools: { Fallback: ToolFallback } };
function UserMessage() { return <MessagePrimitive.Root className="message user"><div className="role">You</div><MessagePrimitive.Content components={partComponents}/><MessagePrimitive.Attachments>{() => null}</MessagePrimitive.Attachments></MessagePrimitive.Root>; }
function AssistantMessage() { return <MessagePrimitive.Root className="message assistant"><div className="role">Agent</div><MessagePrimitive.Content components={partComponents}/><MessagePrimitive.Error/></MessagePrimitive.Root>; }
function ImagePreview({ src, name }: { src: string; name: string }) {
return <Dialog.Root><Dialog.Trigger asChild><button className="image-preview" aria-label={`预览 ${name}`}><img src={src} alt={name}/></button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay"/><Dialog.Content className="image-dialog" aria-describedby={undefined}><Dialog.Title>{name}</Dialog.Title><img src={src} alt={name}/><Dialog.Close className="icon-button" aria-label="关闭预览"><X/></Dialog.Close></Dialog.Content></Dialog.Portal></Dialog.Root>;
function ImagePreview({ src, name, report = false }: { src: string; name: string; report?: boolean }) {
return <Dialog.Root><Dialog.Trigger asChild><button className={`image-preview${report ? " report-image-preview" : ""}`} aria-label={`预览 ${name}`}><img src={src} alt={name}/></button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay"/><Dialog.Content className={`image-dialog${report ? " report-image-dialog" : ""}`} aria-describedby={undefined}><Dialog.Title>{name}</Dialog.Title><img src={src} alt={name}/><Dialog.Close className="icon-button" aria-label="关闭预览"><X/></Dialog.Close></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
function ComposerAttachment() {
const preview = useAuiState((state) => {
+8
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { AuiConfig, AuiProvider, defineToolkit, Tools, useAui, type Toolkit, type ToolCallMessagePartProps } from "@assistant-ui/react";
import type { PropsWithChildren } from "react";
import { z } from "zod";
import { reportArtifactSchema } from "@agent-studio/shared";
import { useHarnessStore } from "../assistant/HarnessContext";
type JsonObject = Record<string, unknown>;
@@ -32,11 +33,18 @@ export function SaveReportDraftUI(props: ToolCallMessagePartProps<JsonObject, un
const content = typeof props.args?.content === "string" ? props.args.content : "";
return <section className="tool-card risk" data-tool="save_report_draft"><header><span>save_report_draft</span><span className="status">{stateLabel(props)}</span></header><p> Session <code>report.md</code></p><p className="summary">{content.slice(0, 180)}{content.length > 180 ? "…" : ""}</p>{props.approval && <ApprovalActions approval={props.approval}/>}</section>;
}
export function SwadsReportUI(props: ToolCallMessagePartProps<JsonObject, unknown>) {
const parsed = reportArtifactSchema.safeParse(props.result);
return <section className="tool-card" data-tool="render_swads_daily_report"><header><span>SW Ads </span><span className="status">{stateLabel(props)}</span></header>{parsed.success && <>{parsed.data.warning && <p role="status">{parsed.data.warning.message}</p>}<div className="report-downloads">{Object.entries(parsed.data.downloads).map(([format, url]) => <a key={format} href={url} download> {format.toUpperCase()}</a>)}</div></>}</section>;
}
export function SwadsCliUI(props: ToolCallMessagePartProps<JsonObject, unknown>) { return <section className="tool-card"><header><span>SW Ads · {String(props.args.command ?? "read")}</span><span className="status">{stateLabel(props)}</span></header></section>; }
export function ToolFallback(props: ToolCallMessagePartProps) { return <section className="tool-card"><header><span>{props.toolName}</span><span className="status">{stateLabel(props)}</span></header><pre>{JSON.stringify(props.args ?? {}, null, 2)}</pre></section>; }
function Metric({ label, value, suffix = "" }: { label: string; value: unknown; suffix?: string }) { return <div><span>{label}</span><strong>{typeof value === "number" ? value.toLocaleString() : "—"}{suffix}</strong></div>; }
function asRecord(value: unknown): JsonObject { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; }
const authoredToolkit = defineToolkit({
swads_cli: { description: "显示 SW Ads 只读查询状态", parameters: z.object({ command: z.string() }), render: SwadsCliUI },
render_swads_daily_report: { description: "日报状态与下载", parameters: z.object({}), render: SwadsReportUI },
mock_ads_metrics: { description: "显示服务端广告指标工具结果", parameters: z.object({ accountId: z.string(), days: z.number() }), render: MockAdsMetricsUI },
save_report_draft: { description: "显示服务端报告保存审批与结果", parameters: z.object({ content: z.string(), title: z.string().optional() }), render: SaveReportDraftUI },
});
+11
View File
@@ -104,3 +104,14 @@ button:disabled { opacity: .46; cursor: not-allowed; }
html, body, #root { overflow: hidden; }.app-shell { grid-template-rows: 58px 44px 1fr; }.brand small, .model-badge, .vision-badge, .session-summary > .run-dot { display: none; }.topbar { padding: 0 10px; }.settings-button { width: 42px; padding: 0; font-size: 0; }.settings-button svg { width: 18px; }.mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-1); }.mobile-tabs button { border: 0; background: transparent; color: var(--muted); text-transform: capitalize; }.mobile-tabs button.active { color: var(--cyan); box-shadow: inset 0 -2px var(--cyan); }.workspace-grid { display: block; min-height: 0; }.mobile-panel { display: none !important; height: 100%; }.mobile-panel.shown { display: block !important; }.side-panel { border: 0; }.chat-toolbar { padding: 6px 10px; }.text-button { padding: 0 8px; }.composer-footer { padding: 10px; }.message { width: calc(100% - 20px); }.metrics { grid-template-columns: repeat(2, 1fr); }.form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: span 1 !important; }.presets { overflow-x: auto; }.presets button { flex: 0 0 auto; }.dialog-actions { flex-wrap: wrap; }.dialog-actions button { flex: 1; }.dialog-actions .danger { margin: 0; }.composer-hint { display: none; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } }
.report-downloads { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 12px; }
.message-text.markdown { white-space: normal; overflow-wrap: anywhere; }
.markdown p { margin: 0.5em 0; }
.markdown pre { overflow-x: auto; white-space: pre; }
.markdown table { display: block; overflow-x: auto; border-collapse: collapse; }
.markdown th, .markdown td { padding: 6px 10px; border: 1px solid var(--line, #ddd); }
.report-image-preview { width: min(640px, 100%); }
.report-image-preview img { width: 100%; height: auto; object-fit: contain; }
.report-image-dialog img { width: 100%; max-height: none; }
+18
View File
@@ -46,3 +46,21 @@ describe("HarnessClientStore reducer", () => {
await expect(store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true })).rejects.toThrow("Session 初始化失败:Failed to fetch");
});
});
describe("generated report events", () => {
it("deduplicates images and merges provisional tool messages with the correct assistant", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["png"], { type: "image/png" }), { headers: { "content-type": "image/png" } })));
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:report-image") });
const store = new HarnessClientStore(); const base = { sessionId: "ses_report", runId: "run_report", timestamp: new Date().toISOString() };
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc";
const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
const artifact = { accountKey, reportDate, reportId, previewUrl: `${url}/png`, downloads: { json: `${url}/json`, html: `${url}/html`, png: `${url}/png` } };
store.reduce({ ...base, eventId: "report_1", sequence: 1, type: "tool.requested", payload: { toolCallId: "render", toolName: "render_swads_daily_report", arguments: {} } });
store.reduce({ ...base, eventId: "report_2", sequence: 2, type: "assistant.delta", payload: { messageId: "real_message", delta: "分析" } });
const event: HarnessEvent = { ...base, eventId: "report_3", sequence: 3, type: "report.generated", payload: { assistantMessageId: "real_message", artifact } };
store.reduce(event); store.reduce(event); store.reduce({ ...event, eventId: "report_4", sequence: 4 });
await vi.waitFor(() => expect(store.getSnapshot().messages[0]?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])));
const messages = store.getSnapshot().messages; expect(messages).toHaveLength(1); expect(messages[0]?.id).toBe("real_message"); expect(messages[0]?.content).toEqual(expect.arrayContaining([{ type: "image", image: "blob:report-image", filename: "SW Ads 2026-09-06" }]));
expect(Array.isArray(messages[0]?.content) && messages[0].content.filter((part) => part.type === "image")).toHaveLength(1);
});
});
+30 -3
View File
@@ -1,15 +1,15 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HarnessEvent } from "@agent-studio/shared";
import { App } from "../src/App";
import { HarnessRuntime } from "../src/assistant/HarnessRuntime";
import { HarnessClientStore } from "../src/assistant/store";
import { MockAdsMetricsUI, SaveReportDraftUI } from "../src/components/ToolUIs";
import { MockAdsMetricsUI, SaveReportDraftUI, SwadsReportUI } from "../src/components/ToolUIs";
import { AppErrorBoundary } from "../src/components/AppErrorBoundary";
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); });
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
describe("web UI", () => {
it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /尚未配置模型/ })).toBeInTheDocument(); });
it("renders the mock metrics Tool UI", () => { render(<MockAdsMetricsUI {...toolProps("mock_ads_metrics", { accountId: "demo-account", days: 7 }, { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } })}/>); expect(screen.getByText("demo-account", { exact: false })).toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); });
@@ -36,7 +36,34 @@ describe("web UI", () => {
expect(screen.getByText("ROAS")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "允许保存报告草稿?" })).toBeInTheDocument();
});
it("shows Markdown, generated PNG preview and downloads for a text-only model", async () => {
const store = new HarnessClientStore(); store.getSnapshot().model.supportsImages = false;
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["png"], { type: "image/png" }), { headers: { "content-type": "image/png" } })));
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:report-image") });
const artifact = reportArtifact();
store.reduce(harnessEvent(1, "tool.requested", { toolCallId: "render", toolName: "render_swads_daily_report", arguments: {} }));
store.reduce(harnessEvent(2, "assistant.delta", { messageId: "assistant-real", delta: "**账户结论**\n\n- 建议一\n- 建议二\n- 建议三" }));
store.reduce(harnessEvent(3, "tool.completed", { toolCallId: "render", toolName: "render_swads_daily_report", result: artifact }));
store.reduce(harnessEvent(4, "report.generated", { assistantMessageId: "assistant-real", artifact }));
store.reduce(harnessEvent(5, "run.completed", {}));
await waitFor(() => expect(store.getSnapshot().messages[0]?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])));
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
expect(screen.getByText("账户结论").tagName).toBe("STRONG"); expect(screen.getAllByRole("listitem")).toHaveLength(3); expect(screen.getByRole("link", { name: "下载 JSON" })).toHaveAttribute("href", artifact.downloads.json);
await userEvent.setup().click(screen.getByRole("button", { name: "预览 SW Ads 2026-09-06" })); expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(store.getSnapshot().messages.filter((message) => message.role === "assistant")).toHaveLength(1);
});
it("shows a no-PNG warning and only available downloads", () => {
const artifact = reportArtifact(); const result = { ...artifact, previewUrl: undefined, downloads: { json: artifact.downloads.json, html: artifact.downloads.html }, warning: { code: "CHROME_UNAVAILABLE", message: "Chrome unavailable; HTML/JSON available" } };
render(<SwadsReportUI {...toolProps("render_swads_daily_report", {}, result)}/>); expect(screen.getByRole("status")).toHaveTextContent("Chrome unavailable"); expect(screen.queryByRole("link", { name: "下载 PNG" })).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
});
function toolProps(toolName: string, args: Record<string, unknown>, result?: unknown, approval?: { id: string }) { return { type: "tool-call" as const, toolCallId: "t1", toolName, args, argsText: JSON.stringify(args), result, isError: false, status: { type: "complete" as const }, addResult: vi.fn(), resume: vi.fn(), respondToApproval: vi.fn(), ...(approval ? { approval } : {}) }; }
function harnessEvent<T extends HarnessEvent["type"]>(sequence: number, type: T, payload: Extract<HarnessEvent, { type: T }>["payload"]): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; }
function reportArtifact() {
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc";
const base = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
return { accountKey, reportDate, reportId, previewUrl: `${base}/png`, downloads: { png: `${base}/png`, html: `${base}/html`, json: `${base}/json` } };
}