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
+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; }