Add Beryl Agent and harness source

This commit is contained in:
2026-09-08 20:10:03 +08:00
parent 06d72d001c
commit 47cfbcd66a
70 changed files with 12558 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+1
View File
@@ -0,0 +1 @@
{"name":"@agent-studio/web","version":"0.1.0","type":"module","scripts":{"dev":"vite","lint":"eslint src tests","typecheck":"tsc -p tsconfig.json --noEmit","test":"vitest run","build":"tsc -p tsconfig.json --noEmit && vite build"},"dependencies":{"@agent-studio/shared":"workspace:*","@assistant-ui/react":"^0.15.16","@assistant-ui/react-markdown":"^0.14.12","lucide-react":"^0.544.0","react":"^19.1.1","react-dom":"^19.1.1"},"devDependencies":{"@testing-library/jest-dom":"^6.8.0","@testing-library/react":"^16.3.0","@types/react":"^19.1.12","@types/react-dom":"^19.1.9","@vitejs/plugin-react":"^5.0.2","jsdom":"^26.1.0","vite":"^8.2.2"}}
+844
View File
@@ -0,0 +1,844 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from "react";
import {
AssistantRuntimeProvider,
ComposerPrimitive,
ThreadPrimitive,
useExternalStoreRuntime,
type AppendMessage,
type ThreadMessageLike,
} from "@assistant-ui/react";
import type {
HarnessEvent,
ImageAttachment,
ModelConnectionInput,
SkillCatalogItem,
} from "@agent-studio/shared";
type Msg = {
id: string;
role: "user" | "assistant";
text: string;
images?: Array<{ src: string; alt: string }>;
status?: "running" | "complete" | "incomplete";
};
type PendingImage = ImageAttachment & { previewUrl: string };
const demo =
"请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。";
const modelStorageKey = "agent-studio:model-config:v1";
const defaultModelInput: ModelConnectionInput = {
providerId: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
modelId: "gpt-5-mini",
supportsImages: true,
supportsVideo: false,
};
function readSavedModel(): ModelConnectionInput | undefined {
try {
const value = localStorage.getItem(modelStorageKey);
return value ? (JSON.parse(value) as ModelConnectionInput) : undefined;
} catch {
return undefined;
}
}
function saveModel(input: ModelConnectionInput) {
localStorage.setItem(modelStorageKey, JSON.stringify(input));
}
async function fetchJsonWithRetry<T>(
input: RequestInfo | URL,
init?: RequestInit,
attempts = 10,
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await fetch(input, init);
if (!response.ok) throw new Error(`HTTP_${response.status}`);
return (await response.json()) as T;
} catch (error) {
lastError = error;
if (attempt + 1 < attempts)
await new Promise((resolve) => setTimeout(resolve, 300));
}
}
throw lastError;
}
export function App() {
const [session, setSession] = useState("");
const [skills, setSkills] = useState<SkillCatalogItem[]>([]);
const [events, setEvents] = useState<HarnessEvent[]>([]);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [running, setRunning] = useState(false);
const [settings, setSettings] = useState(false);
const [configured, setConfigured] = useState(false);
const [restoring, setRestoring] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [attachmentError, setAttachmentError] = useState("");
const es = useRef<EventSource | undefined>(undefined);
const messagesRef = useRef<HTMLDivElement | null>(null);
const create = useCallback(async () => {
es.current?.close();
const d = await fetchJsonWithRetry<{ sessionId: string }>(
"/api/sessions",
{ method: "POST" },
);
setSession(d.sessionId);
setEvents([]);
setMsgs([]);
setRunning(false);
setPendingImages((old) => {
old.forEach((image) => URL.revokeObjectURL(image.previewUrl));
return [];
});
setAttachmentError("");
setConfigured(false);
const savedModel = readSavedModel();
if (savedModel) {
setRestoring(true);
try {
const restored = await fetch(
`/api/sessions/${d.sessionId}/model-config`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(savedModel),
},
);
setConfigured(restored.ok);
} finally {
setRestoring(false);
}
}
return d.sessionId as string;
}, []);
useEffect(() => {
void fetchJsonWithRetry<{ skills: SkillCatalogItem[] }>("/api/skills")
.then((d) => setSkills(d.skills));
void create();
return () => es.current?.close();
}, [create]);
useEffect(() => {
if (!session) return;
const source = new EventSource(`/api/sessions/${session}/events`);
es.current = source;
source.onmessage = (ev) => consume(JSON.parse(ev.data));
for (const t of [
"session.started",
"user.message",
"assistant.delta",
"assistant.completed",
"tool.requested",
"tool.completed",
"tool.failed",
"approval.required",
"approval.resolved",
"run.started",
"run.completed",
"run.failed",
"run.cancelled",
])
source.addEventListener(t, (ev) =>
consume(JSON.parse((ev as MessageEvent).data)),
);
function consume(e: HarnessEvent) {
setEvents((old) =>
old.some((x) => x.eventId === e.eventId)
? old
: [...old, e].sort((a, b) => a.sequence - b.sequence),
);
if (e.type === "user.message")
setMsgs((old) => [
...old,
{ id: e.eventId, role: "user", text: String(e.payload.text ?? "") },
]);
if (e.type === "assistant.delta")
setMsgs((old) => {
const id = `assistant-${e.runId}`;
const index = old.findIndex((message) => message.id === id);
if (index === -1)
return [
...old,
{
id,
role: "assistant",
text: String(e.payload.delta ?? ""),
status: "running",
},
];
return old.map((message, messageIndex) =>
messageIndex === index
? {
...message,
text: message.text + String(e.payload.delta ?? ""),
status: "running",
}
: message,
);
});
if (e.type === "tool.completed" && e.payload.toolName === "save_report_image") {
const result = e.payload.result as
| { details?: { reportType?: "daily" | "weekly" } }
| undefined;
const reportType = result?.details?.reportType;
if (reportType)
setMsgs((old) => {
const id = `report-image-${e.eventId}`;
if (old.some((message) => message.id === id)) return old;
const label = reportType === "daily" ? "日报" : "周报";
return [
...old,
{
id,
role: "assistant",
text: `${label}图片`,
images: [
{
src: `/api/sessions/${session}/reports/${reportType}?v=${e.eventId}`,
alt: `${label}图片`,
},
],
status: "complete",
},
];
});
}
if (e.type === "run.completed")
setMsgs((old) =>
old.map((m) =>
m.id === `assistant-${e.runId}` ? { ...m, status: "complete" } : m,
),
);
if (e.type === "run.failed" || e.type === "run.cancelled")
setMsgs((old) =>
old.map((m) =>
m.id === `assistant-${e.runId}` ? { ...m, status: "incomplete" } : m,
),
);
if (e.type === "run.started") setRunning(true);
if (["run.completed", "run.failed", "run.cancelled"].includes(e.type))
setRunning(false);
}
return () => source.close();
}, [session]);
useLayoutEffect(() => {
const container = messagesRef.current;
if (!container) return;
container.scrollTop = container.scrollHeight;
}, [msgs]);
const onNew = async (message: AppendMessage) => {
const text = message.content
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
const response = await fetch(`/api/sessions/${session}/messages`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text,
attachmentIds: pendingImages.map((image) => image.attachmentId),
}),
});
if (response.ok) {
pendingImages.forEach((image) => URL.revokeObjectURL(image.previewUrl));
setPendingImages([]);
setAttachmentError("");
}
};
const onPasteImage = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (!files.length) return;
event.preventDefault();
setAttachmentError("");
const available = Math.max(0, 4 - pendingImages.length);
if (!available) {
setAttachmentError("每条消息最多粘贴 4 张图片。");
return;
}
for (const [index, file] of files.slice(0, available).entries()) {
const form = new FormData();
form.append("file", file, file.name || `clipboard-${Date.now()}-${index}.png`);
const response = await fetch(`/api/sessions/${session}/attachments`, {
method: "POST",
body: form,
});
if (!response.ok) {
setAttachmentError("图片上传失败,请确认格式为 PNG、JPEG 或 WebP,且小于 5 MB。");
continue;
}
const uploaded = (await response.json()) as ImageAttachment;
setPendingImages((old) => [
...old,
{ ...uploaded, previewUrl: URL.createObjectURL(file) },
]);
}
};
const removePendingImage = async (image: PendingImage) => {
const response = await fetch(
`/api/sessions/${session}/attachments/${image.attachmentId}`,
{ method: "DELETE" },
);
if (!response.ok) return;
URL.revokeObjectURL(image.previewUrl);
setPendingImages((old) =>
old.filter((item) => item.attachmentId !== image.attachmentId),
);
};
const runtime = useExternalStoreRuntime<Msg>({
messages: msgs,
isRunning: running,
isSendDisabled: !configured,
onNew,
onCancel: async () => {
const response = await fetch(`/api/sessions/${session}/cancel`, {
method: "POST",
});
if (response.ok) setRunning(false);
},
convertMessage: (m): ThreadMessageLike => ({
id: m.id,
role: m.role,
content: [{ type: "text", text: m.text }],
createdAt: new Date(),
status:
m.role === "assistant"
? m.status === "running"
? { type: "running" }
: m.status === "incomplete"
? { type: "incomplete", reason: "error" }
: { type: "complete", reason: "stop" }
: undefined,
}),
});
const approvals = events
.filter((e) => e.type === "approval.required")
.filter(
(e) =>
!events.some(
(x) =>
x.type === "approval.resolved" &&
x.payload.approvalId === e.payload.approvalId,
),
);
return (
<AssistantRuntimeProvider runtime={runtime}>
<div className="app">
<header>
<div>
<b>Agent Studio</b>
<span className="mini"> MINI</span>
</div>
<div className="badges">
<span>{running ? "● RUNNING" : "● READY"}</span>
<span>
{restoring
? "MODEL RESTORING"
: configured
? "MODEL CONNECTED"
: "MODEL MISSING"}
</span>
<button onClick={() => setSettings(true)}></button>
<button onClick={() => void create()}> Session</button>
</div>
</header>
<main>
<aside>
<h2>Skills Catalog</h2>
{skills.map((s) => (
<article key={s.filePath}>
<strong>{s.name}</strong>
<p>{s.description}</p>
<small>
{s.source} ·{" "}
{s.diagnostics.length ? "⚠ Diagnostic" : "✓ Valid"}
</small>
</article>
))}
</aside>
<section className="chat">
<ThreadPrimitive.Root>
<ThreadPrimitive.Viewport className="viewport">
<div className="messages" ref={messagesRef}>
{msgs.length ? (
msgs.map((m) => (
<div key={m.id} className={`message ${m.role}`}>
<b>{m.role === "user" ? "YOU" : "AGENT"}</b>
<p>{m.text || "…"}</p>
{m.images?.map((reportImage) => (
<a
className="report-image-link"
href={reportImage.src}
target="_blank"
rel="noreferrer"
key={reportImage.src}
>
<img
className="report-image"
src={reportImage.src}
alt={reportImage.alt}
/>
</a>
))}
</div>
))
) : (
<div className="empty">
<h1>Beryl Agent</h1>
<p>
广
</p>
<button
onClick={() => navigator.clipboard.writeText(demo)}
>
Demo Prompt
</button>
</div>
)}
</div>
<ComposerPrimitive.Root className="composer">
{pendingImages.length > 0 && (
<div className="attachment-tray" aria-label="已粘贴图片">
{pendingImages.map((image) => (
<div className="attachment-preview" key={image.attachmentId}>
<img src={image.previewUrl} alt={image.name} />
<button
type="button"
aria-label={`删除图片 ${image.name}`}
onClick={() => void removePendingImage(image)}
>
×
</button>
</div>
))}
</div>
)}
{attachmentError && (
<p className="attachment-error" role="alert">
{attachmentError}
</p>
)}
<ComposerPrimitive.Input
aria-label="消息"
placeholder={configured ? "输入任务…" : "请先配置模型"}
onPaste={(event) => void onPasteImage(event)}
/>
<div className="composer-actions">
<ComposerPrimitive.Cancel asChild>
<button disabled={!running}></button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<button disabled={!configured || running}></button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
</section>
<LoopPanel events={events} running={running} />
</main>
{approvals.map((e) => (
<div
className="modal"
role="dialog"
aria-modal="true"
aria-label="工具审批"
key={e.eventId}
>
<div>
<h2></h2>
<p>
<b>{String(e.payload.toolName)}</b> Session
{e.payload.toolName === "save_report_image"
? " 报告图片,并在页面内显示。"
: " 报告草稿。"}
</p>
<pre>{JSON.stringify(e.payload.arguments, null, 2)}</pre>
<button
className="deny"
onClick={() => decision(String(e.payload.approvalId), "deny")}
>
</button>
<button
className="approve"
onClick={() =>
decision(String(e.payload.approvalId), "approve")
}
>
</button>
</div>
</div>
))}
{settings && (
<Settings
session={session}
recoverSession={create}
onClose={() => setSettings(false)}
onSaved={() => {
setConfigured(true);
setSettings(false);
}}
onCleared={() => setConfigured(false)}
/>
)}
</div>
</AssistantRuntimeProvider>
);
async function decision(id: string, d: "approve" | "deny") {
await fetch(`/api/approvals/${id}/${d}`, { method: "POST" });
}
}
const toolLabels: Record<string, string> = {
swads_whoami: "连接 SW Ads",
metrics_catalog: "读取指标目录",
metrics_semantic_query: "查询广告数据",
commerce_list_products: "读取商品列表",
browser_search: "搜索公开网页",
browser_open: "读取公开网页",
h3_preflight: "检查视频参数",
h3_workers: "检查 H3 节点",
h3_init: "创建 H3 项目",
generate_product_anchor: "生成商品锚点图",
h3_render: "生成 H3 视频",
h3_verify: "校验视频成片",
save_report_draft: "保存报告草稿",
save_report_image: "生成报告图片",
};
function LoopPanel({
events,
running,
}: {
events: HarnessEvent[];
running: boolean;
}) {
const latestRunId = events
.slice()
.reverse()
.find((event) => event.runId)?.runId;
const runEvents = latestRunId
? events.filter((event) => event.runId === latestRunId)
: [];
const pendingApproval = runEvents.find(
(event) =>
event.type === "approval.required" &&
!runEvents.some(
(candidate) =>
candidate.type === "approval.resolved" &&
candidate.payload.approvalId === event.payload.approvalId,
),
);
const toolCalls = runEvents
.filter((event) => event.type === "tool.requested")
.map((requested) => {
const finished = runEvents.find(
(event) =>
(event.type === "tool.completed" || event.type === "tool.failed") &&
event.payload.toolCallId === requested.payload.toolCallId,
);
return { requested, finished };
});
const activeTool = toolCalls.find((call) => !call.finished);
const terminal = runEvents
.slice()
.reverse()
.find((event) =>
["run.completed", "run.failed", "run.cancelled"].includes(event.type),
);
const state = terminal?.type === "run.completed"
? "success"
: terminal?.type === "run.failed"
? "error"
: terminal?.type === "run.cancelled"
? "cancelled"
: pendingApproval
? "approval"
: activeTool
? "tool"
: running
? "thinking"
: "idle";
const title = state === "success"
? "任务已完成"
: state === "error"
? "运行失败"
: state === "cancelled"
? "任务已停止"
: state === "approval"
? "等待你的确认"
: state === "tool"
? toolLabels[String(activeTool?.requested.payload.toolName)] ??
`调用 ${String(activeTool?.requested.payload.toolName)}`
: state === "thinking"
? "Agent 正在思考"
: "等待新任务";
const lastEvent = runEvents.at(-1);
const phases = [
{
label: "接收任务",
complete: runEvents.some((event) => event.type === "run.started"),
active: state === "thinking" && toolCalls.length === 0,
},
{
label: "分析与规划",
complete: toolCalls.length > 0 || state === "success",
active: state === "thinking" && toolCalls.length > 0,
},
{
label: "工具执行",
complete: toolCalls.length > 0 && toolCalls.every((call) => call.finished),
active: state === "tool",
},
{
label: "人工确认",
complete: runEvents.some((event) => event.type === "approval.resolved"),
active: state === "approval",
optional: !runEvents.some((event) => event.type.startsWith("approval.")),
},
{
label: "整理结果",
complete: state === "success",
active: state === "thinking" && toolCalls.every((call) => call.finished),
},
];
return (
<aside className="loop-panel">
<div className={`loop-status ${state}`}>
<div className="status-icon" aria-hidden="true">
{state === "success" ? "✓" : state === "error" ? "!" : "●"}
</div>
<div>
<span>AGENT LOOP</span>
<strong>{title}</strong>
<small>
{lastEvent
? `事件 #${lastEvent.sequence} · ${formatTime(lastEvent.timestamp)}`
: "发送任务后,这里会显示实时进度"}
</small>
</div>
</div>
<section className="phase-card">
<h2></h2>
<ol className="phase-list">
{phases.map((phase) => (
<li
key={phase.label}
className={`${phase.complete ? "complete" : ""} ${phase.active ? "active" : ""} ${phase.optional ? "optional" : ""}`}
>
<i>{phase.complete ? "✓" : phase.active ? "●" : ""}</i>
<span>{phase.label}</span>
{phase.optional && <em></em>}
</li>
))}
</ol>
</section>
<section className="tool-card">
<div className="section-title">
<h2></h2>
<span>{toolCalls.length}</span>
</div>
{toolCalls.length === 0 ? (
<p className="muted-copy"></p>
) : (
<div className="tool-list">
{toolCalls
.slice()
.reverse()
.map(({ requested, finished }) => {
const name = String(requested.payload.toolName);
const failed = finished?.type === "tool.failed";
return (
<div className="tool-row" key={String(requested.payload.toolCallId)}>
<i className={failed ? "failed" : finished ? "done" : "busy"}>
{failed ? "!" : finished ? "✓" : ""}
</i>
<div>
<strong>{toolLabels[name] ?? name}</strong>
<small>
{failed ? "执行失败" : finished ? "已完成" : "执行中"}
</small>
</div>
</div>
);
})}
</div>
)}
</section>
{terminal?.type === "run.failed" && (
<section className="error-card">
<h2></h2>
<p>{String(terminal.payload.message ?? terminal.payload.code ?? "未知错误")}</p>
</section>
)}
<details className="debug-events">
<summary> <span>{events.length}</span></summary>
{events
.slice()
.reverse()
.map((event) => (
<details key={event.eventId}>
<summary>
<span>#{event.sequence}</span> {event.type}
</summary>
<pre>{JSON.stringify(event.payload, null, 2)}</pre>
</details>
))}
</details>
</aside>
);
}
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date(value));
}
function Settings({
session,
recoverSession,
onClose,
onSaved,
onCleared,
}: {
session: string;
recoverSession: () => Promise<string>;
onClose: () => void;
onSaved: () => void;
onCleared: () => void;
}) {
const [input, setInput] = useState<ModelConnectionInput>(
() => readSavedModel() ?? defaultModelInput,
);
const save = async () => {
const submit = (sessionId: string) =>
fetch(`/api/sessions/${sessionId}/model-config`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
let r = await submit(session);
if (!r.ok) {
const problem = await r.json();
const message = String(problem.error?.message ?? problem.message ?? "保存失败");
if (message.includes("SESSION_NOT_FOUND")) {
const freshSession = await recoverSession();
r = await submit(freshSession);
} else {
alert(message);
return;
}
}
if (r.ok) {
saveModel(input);
onSaved();
}
else alert((await r.json()).error?.message);
};
const clearSaved = async () => {
localStorage.removeItem(modelStorageKey);
await fetch(`/api/sessions/${session}/model-config/credentials`, {
method: "DELETE",
});
setInput(defaultModelInput);
onCleared();
};
return (
<div
className="modal"
role="dialog"
aria-modal="true"
aria-label="模型连接"
>
<div>
<h2></h2>
<label>
Provider
<input
value={input.providerId}
onChange={(e) => setInput({ ...input, providerId: e.target.value })}
/>
</label>
<label>
API
<select
value={input.api}
onChange={(e) =>
setInput({
...input,
api: e.target.value as ModelConnectionInput["api"],
})
}
>
<option>openai-responses</option>
<option>openai-completions</option>
<option>anthropic-messages</option>
</select>
</label>
<label>
Base URL
<input
value={input.baseUrl}
onChange={(e) => setInput({ ...input, baseUrl: e.target.value })}
/>
</label>
<label>
Model ID
<input
value={input.modelId}
onChange={(e) => setInput({ ...input, modelId: e.target.value })}
/>
</label>
<label>
API Key
<input
type="password"
value={input.apiKey ?? ""}
onChange={(e) => setInput({ ...input, apiKey: e.target.value })}
/>
</label>
<label className="check">
<input
type="checkbox"
checked={input.supportsImages}
onChange={(e) =>
setInput({ ...input, supportsImages: e.target.checked })
}
/>
</label>
<label className="check">
<input
type="checkbox"
checked={input.supportsVideo ?? false}
onChange={(e) =>
setInput({ ...input, supportsVideo: e.target.checked })
}
/>
</label>
<button onClick={onClose}></button>
<button className="deny" onClick={() => void clearSaved()}>
</button>
<button className="approve" onClick={() => void save()}>
</button>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
import React from 'react';import{createRoot}from'react-dom/client';import{App}from'./App.js';import'./styles/app.css';createRoot(document.getElementById('root')!).render(<React.StrictMode><App/></React.StrictMode>);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { App } from "../src/App.js";
global.ResizeObserver = class { observe(){} unobserve(){} disconnect(){} };
afterEach(cleanup);
describe("App", () => {
it("renders three principal surfaces", async () => {
localStorage.clear();
const fetchMock = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ skills: [], sessionId: "s" }),
ok: true,
}),
);
global.fetch = fetchMock as unknown as typeof fetch;
global.EventSource = class {
close() {}
addEventListener() {}
set onmessage(_: unknown) {}
} as unknown as typeof EventSource;
render(<App />);
expect(screen.getByText("Skills Catalog")).toBeInTheDocument();
expect(screen.getByText("AGENT LOOP")).toBeInTheDocument();
expect(screen.getByText("运行阶段")).toBeInTheDocument();
expect(screen.getByText("工具任务")).toBeInTheDocument();
expect(screen.getByLabelText("消息")).toBeInTheDocument();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
});
it("shows a completed daily report image in the conversation", async () => {
localStorage.clear();
global.fetch = vi.fn((input: RequestInfo | URL) =>
Promise.resolve({
json: () =>
Promise.resolve(
String(input).includes("/api/skills")
? { skills: [] }
: { sessionId: "session-report" },
),
ok: true,
}),
) as unknown as typeof fetch;
let reportListener: ((event: MessageEvent) => void) | undefined;
global.EventSource = class {
close() {}
set onmessage(_: unknown) {}
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
if (type === "tool.completed")
reportListener = listener as (event: MessageEvent) => void;
}
} as unknown as typeof EventSource;
render(<App />);
await waitFor(() => expect(reportListener).toBeDefined());
act(() =>
reportListener?.({
data: JSON.stringify({
eventId: "evt-report",
runId: "run-report",
sessionId: "session-report",
sequence: 1,
timestamp: new Date().toISOString(),
type: "tool.completed",
payload: {
toolName: "save_report_image",
result: { details: { reportType: "daily" } },
},
}),
} as MessageEvent),
);
const image = await screen.findByRole("img", { name: "日报图片" });
expect(image).toHaveAttribute(
"src",
"/api/sessions/session-report/reports/daily?v=evt-report",
);
});
});
+1
View File
@@ -0,0 +1 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"jsx":"react-jsx","lib":["ES2022","DOM","DOM.Iterable"],"types":["vite/client"]},"include":["src","tests","vite.config.ts"]}
@@ -0,0 +1,2 @@
import {defineConfig} from 'vite'; import react from '@vitejs/plugin-react';
export default defineConfig({plugins:[react()],server:{port:5173,watch:{usePolling:true,interval:1000},proxy:{'/api':'http://localhost:3001','/health':'http://localhost:3001'}}});
@@ -0,0 +1 @@
import{defineConfig}from'vitest/config';export default defineConfig({test:{environment:'jsdom'}});