Add Beryl Agent and harness source
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"name":"@agent-studio/server","version":"0.1.0","type":"module","scripts":{"dev":"tsx src/index.ts","lint":"eslint src tests","typecheck":"tsc -p tsconfig.json --noEmit","test":"vitest run","build":"tsc -p tsconfig.json"},"dependencies":{"@agent-studio/harness":"workspace:*","@agent-studio/shared":"workspace:*","@fastify/cors":"^11.1.0","@fastify/multipart":"^9.2.1","fastify":"^5.5.0","zod":"^4.1.5"},"devDependencies":{"tsx":"^4.20.5"}}
|
||||
@@ -0,0 +1,276 @@
|
||||
import Fastify from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import cors from "@fastify/cors";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MessageInputSchema,
|
||||
ModelConnectionInputSchema,
|
||||
} from "@agent-studio/shared";
|
||||
import { SessionRegistry, SkillCatalog } from "@agent-studio/harness";
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../",
|
||||
);
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: { redact: ["req.headers.authorization", "req.body.apiKey"] },
|
||||
bodyLimit: 6 * 1024 * 1024,
|
||||
genReqId: () => `req_${randomUUID()}`,
|
||||
});
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
fileSize: Number(process.env.MAX_IMAGE_BYTES) || 5 * 1024 * 1024,
|
||||
files: 1,
|
||||
},
|
||||
});
|
||||
const registry = new SessionRegistry(root);
|
||||
const skills = new SkillCatalog(root);
|
||||
await skills.reload();
|
||||
app.decorate("registry", registry);
|
||||
app.get("/health", async (req) => ({ ok: true, requestId: req.id }));
|
||||
app.get("/api/skills", async (req) => ({
|
||||
skills: skills.items,
|
||||
requestId: req.id,
|
||||
}));
|
||||
app.post("/api/sessions", async (req, reply) => {
|
||||
const s = await registry.create();
|
||||
return reply.code(201).send({ sessionId: s.sessionId, requestId: req.id });
|
||||
});
|
||||
app.delete("/api/sessions/:id", async (req, reply) => {
|
||||
await registry.delete((req.params as { id: string }).id);
|
||||
return reply.send({ ok: true, requestId: req.id });
|
||||
});
|
||||
app.get("/api/sessions/:id/model-config", async (req) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
registry.require(id);
|
||||
return {
|
||||
config: registry.models.getRedacted(id) ?? {
|
||||
configured: false,
|
||||
source: "missing",
|
||||
},
|
||||
requestId: req.id,
|
||||
};
|
||||
});
|
||||
app.put("/api/sessions/:id/model-config", async (req, reply) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
const s = registry.require(id);
|
||||
if (s.busy)
|
||||
throw Object.assign(new Error("SESSION_BUSY"), { statusCode: 409 });
|
||||
const parsed = ModelConnectionInputSchema.parse(req.body);
|
||||
const environmentKey =
|
||||
parsed.api === "anthropic-messages"
|
||||
? process.env.ANTHROPIC_API_KEY
|
||||
: process.env.OPENAI_API_KEY;
|
||||
const apiKey = parsed.apiKey?.trim() || environmentKey?.trim();
|
||||
if (!apiKey)
|
||||
return reply
|
||||
.code(400)
|
||||
.send(
|
||||
error(
|
||||
"API_KEY_REQUIRED",
|
||||
`请填写 ${parsed.api === "anthropic-messages" ? "Anthropic" : "OpenAI"} API Key`,
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
const input = { ...parsed, apiKey };
|
||||
registry.resetAgent(id);
|
||||
const config = await registry.models.configure(id, input);
|
||||
registry.events.append(id, {
|
||||
type: "model.configured",
|
||||
payload: { ...config },
|
||||
});
|
||||
return reply.send({ config, requestId: req.id });
|
||||
});
|
||||
app.delete("/api/sessions/:id/model-config/credentials", async (req) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
registry.require(id);
|
||||
registry.models.clearCredentials(id);
|
||||
return { ok: true, requestId: req.id };
|
||||
});
|
||||
app.post("/api/sessions/:id/model-config/test", async (req) => {
|
||||
registry.require((req.params as { id: string }).id);
|
||||
return {
|
||||
status: "deferred",
|
||||
message: "配置已保存,将在首次运行时验证",
|
||||
requestId: req.id,
|
||||
};
|
||||
});
|
||||
app.post("/api/sessions/:id/attachments", async (req, reply) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
const s = registry.require(id);
|
||||
const part = await req.file();
|
||||
if (!part) throw new Error("UPLOAD_REQUIRED");
|
||||
const data = await part.toBuffer();
|
||||
const item = await registry.attachments.add(
|
||||
id,
|
||||
s.workspace.root,
|
||||
part.filename,
|
||||
part.mimetype,
|
||||
data,
|
||||
);
|
||||
registry.events.append(id, {
|
||||
type: "attachment.uploaded",
|
||||
payload: { ...item },
|
||||
});
|
||||
return reply.code(201).send({ ...item, requestId: req.id });
|
||||
});
|
||||
app.delete("/api/sessions/:id/attachments/:att", async (req) => {
|
||||
const p = req.params as { id: string; att: string };
|
||||
registry.require(p.id);
|
||||
await registry.attachments.remove(p.id, p.att);
|
||||
return { ok: true, requestId: req.id };
|
||||
});
|
||||
app.get("/api/sessions/:id/reports/:kind", async (req, reply) => {
|
||||
const params = req.params as { id: string; kind: string };
|
||||
const session = registry.require(params.id);
|
||||
if (params.kind !== "daily" && params.kind !== "weekly")
|
||||
throw Object.assign(new Error("REPORT_KIND_NOT_FOUND"), { statusCode: 404 });
|
||||
const file = session.workspace.resolve(`swads-${params.kind}-report.png`);
|
||||
const png = await readFile(file);
|
||||
return reply.header("cache-control", "no-store").type("image/png").send(png);
|
||||
});
|
||||
app.post("/api/sessions/:id/messages", async (req, reply) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
const s = registry.require(id);
|
||||
if (s.busy)
|
||||
return reply
|
||||
.code(409)
|
||||
.send(error("SESSION_BUSY", "Session is busy", req.id));
|
||||
const input = MessageInputSchema.parse(req.body);
|
||||
const config = registry.models.get(id);
|
||||
if (!config)
|
||||
return reply
|
||||
.code(400)
|
||||
.send(error("MODEL_NOT_CONFIGURED", "Model is not configured", req.id));
|
||||
if (input.attachmentIds.length && !config.supportsImages)
|
||||
return reply
|
||||
.code(422)
|
||||
.send(
|
||||
error(
|
||||
"MODEL_DOES_NOT_SUPPORT_IMAGES",
|
||||
"Selected model does not support images",
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
const attachmentMeta = input.attachmentIds.map((attachmentId) => {
|
||||
const meta = registry.attachments.get(id, attachmentId);
|
||||
if (!meta) throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
return meta;
|
||||
});
|
||||
const images = await Promise.all(
|
||||
attachmentMeta.map(async (meta) => {
|
||||
return {
|
||||
type: "image" as const,
|
||||
mimeType: meta.mediaType,
|
||||
data: (await registry.attachments.content(id, meta.attachmentId)).toString(
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const localFiles = attachmentMeta.map(
|
||||
(meta) =>
|
||||
`attachments/${meta.attachmentId}.${meta.mediaType.split("/")[1]}`,
|
||||
);
|
||||
const promptText = localFiles.length
|
||||
? `${input.text}\n\nLocal attachment paths in this Session workspace:\n${localFiles.map((file) => `- ${file}`).join("\n")}`
|
||||
: input.text;
|
||||
const runId = await registry.run(s, promptText, images);
|
||||
return reply.code(202).send({ runId, requestId: req.id });
|
||||
});
|
||||
app.post("/api/sessions/:id/cancel", async (req) => {
|
||||
await registry.cancel((req.params as { id: string }).id);
|
||||
return { ok: true, requestId: req.id };
|
||||
});
|
||||
app.post("/api/approvals/:id/approve", async (req) => ({
|
||||
approval: registry.approvals.resolve(
|
||||
(req.params as { id: string }).id,
|
||||
"approved",
|
||||
),
|
||||
requestId: req.id,
|
||||
}));
|
||||
app.post("/api/approvals/:id/deny", async (req) => ({
|
||||
approval: registry.approvals.resolve(
|
||||
(req.params as { id: string }).id,
|
||||
"denied",
|
||||
),
|
||||
requestId: req.id,
|
||||
}));
|
||||
app.get("/api/sessions/:id/events", async (req, reply) => {
|
||||
const id = (req.params as { id: string }).id;
|
||||
registry.require(id);
|
||||
const query = req.query as { afterSequence?: string };
|
||||
const last =
|
||||
typeof req.headers["last-event-id"] === "string"
|
||||
? req.headers["last-event-id"]
|
||||
: undefined;
|
||||
const previous = registry.events.listAfter(id, 0);
|
||||
const byId = last
|
||||
? previous.find((e) => e.eventId === last)?.sequence
|
||||
: undefined;
|
||||
const cursor = Number(query.afterSequence ?? byId ?? 0);
|
||||
reply.raw.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
});
|
||||
const send = (e: ReturnType<typeof registry.events.append>) =>
|
||||
reply.raw.write(
|
||||
`id: ${e.eventId}\nevent: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`,
|
||||
);
|
||||
registry.events.listAfter(id, cursor).forEach(send);
|
||||
const unsub = registry.events.subscribe(id, send);
|
||||
const heartbeat = setInterval(
|
||||
() => reply.raw.write(": heartbeat\n\n"),
|
||||
15000,
|
||||
);
|
||||
req.raw.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
unsub();
|
||||
});
|
||||
return reply;
|
||||
});
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
const failure = err as Error & { statusCode?: number; issues?: unknown };
|
||||
const code =
|
||||
failure.name === "ZodError"
|
||||
? "VALIDATION_ERROR"
|
||||
: failure.message || "INTERNAL_ERROR";
|
||||
const status =
|
||||
failure.statusCode ??
|
||||
(code.includes("NOT_FOUND")
|
||||
? 404
|
||||
: code.includes("TOO_LARGE")
|
||||
? 413
|
||||
: code.includes("PRIVATE") || code.includes("UNSAFE")
|
||||
? 400
|
||||
: 500);
|
||||
reply
|
||||
.code(status)
|
||||
.send(
|
||||
error(
|
||||
code,
|
||||
status === 500 ? "Internal server error" : failure.message,
|
||||
req.id,
|
||||
failure.name === "ZodError" ? { issues: failure.issues } : {},
|
||||
),
|
||||
);
|
||||
});
|
||||
app.addHook("onClose", async () => {
|
||||
for (const id of [] as string[]) await registry.delete(id);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
function error(
|
||||
code: string,
|
||||
message: string,
|
||||
requestId: string,
|
||||
details: Record<string, unknown> = {},
|
||||
) {
|
||||
return { error: { code, message, requestId, details } };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
const projectRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../",
|
||||
);
|
||||
try {
|
||||
process.loadEnvFile(path.join(projectRoot, ".env"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
const app = await buildApp();
|
||||
await app.listen({ host: "0.0.0.0", port: Number(process.env.PORT) || 3001 });
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildApp } from "../src/app.js";
|
||||
let app: FastifyInstance;
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
});
|
||||
afterAll(async () => app.close());
|
||||
describe("server", () => {
|
||||
it("is healthy", async () => {
|
||||
const r = await app.inject({ url: "/health" });
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().requestId).toMatch(/^req_/);
|
||||
});
|
||||
it("creates a session and redacts model key", async () => {
|
||||
const c = await app.inject({ method: "POST", url: "/api/sessions" });
|
||||
const id = c.json().sessionId;
|
||||
const r = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/sessions/${id}/model-config`,
|
||||
payload: {
|
||||
providerId: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "secret-value",
|
||||
modelId: "demo",
|
||||
supportsImages: true,
|
||||
},
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.body).not.toContain("secret-value");
|
||||
expect(r.json().config.keyHint).toBe("••••alue");
|
||||
});
|
||||
it("returns unified 404", async () => {
|
||||
const r = await app.inject({ url: "/api/sessions/nope/model-config" });
|
||||
expect(r.statusCode).toBe(404);
|
||||
expect(r.json().error.code).toBe("SESSION_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{"extends":"../../tsconfig.base.json","compilerOptions":{"outDir":"dist","rootDir":"."},"include":["src","tests"]}
|
||||
@@ -0,0 +1 @@
|
||||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
@@ -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"}}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'}});
|
||||
Reference in New Issue
Block a user