feat(swads): add daily image report workflow
This commit is contained in:
+14
-3
@@ -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 }); });
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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` } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user