Initial Daily Campaign Agent release
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@agent-studio/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/index.js" },
|
||||
"dependencies": {
|
||||
"@agent-studio/harness": "workspace:*", "@agent-studio/shared": "workspace:*",
|
||||
"@fastify/cors": "latest", "@fastify/multipart": "latest", "fastify": "5.12.1", "zod": "latest"
|
||||
},
|
||||
"devDependencies": { "tsx": "latest" }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import path from "node:path";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { z } from "zod";
|
||||
import { accountProfileSchema, messageInputSchema, modelConnectionInputSchema } from "@agent-studio/shared";
|
||||
import { HarnessError, MemoryApprovalBroker, MemoryAttachmentStore, MemoryEventStore, MemoryModelConnectionStore, MemorySessionRegistry, PersistentAccountProfileStore, SkillCatalog, safeError, type HarnessSession } from "@agent-studio/harness";
|
||||
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const defaultRoot = path.resolve(moduleDir, "../../..");
|
||||
const idParams = z.object({ sessionId: z.string().min(1) });
|
||||
const attachmentParams = z.object({ sessionId: z.string().min(1), attachmentId: z.string().min(1) });
|
||||
const artifactParams = z.object({ sessionId: z.string().min(1), filename: z.string().regex(/^\d{4}-\d{2}-\d{2}-campaign-triage\.docx$/) });
|
||||
const approvalParams = z.object({ approvalId: z.string().min(1) });
|
||||
const intEnv = (name: string, fallback: number) => { const value = Number(process.env[name]); return Number.isSafeInteger(value) && value > 0 ? value : fallback; };
|
||||
|
||||
export type AppContext = { app: FastifyInstance; events: MemoryEventStore; approvals: MemoryApprovalBroker; models: MemoryModelConnectionStore; attachments: MemoryAttachmentStore; sessions: MemorySessionRegistry; skills: SkillCatalog; accountProfiles: PersistentAccountProfileStore };
|
||||
|
||||
function sessionOrThrow(sessions: MemorySessionRegistry, id: string): HarnessSession { const session = sessions.get(id); if (!session) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); return session; }
|
||||
function success(request: FastifyRequest, data: Record<string, unknown> = {}) { return { ...data, requestId: request.id }; }
|
||||
|
||||
export async function createApp(projectRoot = defaultRoot, persistentModelPath = process.env.NODE_ENV === "test" ? undefined : process.env.MODEL_CONFIG_PATH ?? path.join(projectRoot, "workspace", ".agent-studio", "model-connection.json"), persistentAccountProfilePath = process.env.NODE_ENV === "test" ? undefined : process.env.ACCOUNT_PROFILE_PATH || path.join(projectRoot, "workspace", ".agent-studio", "account-profile.json")): Promise<AppContext> {
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info", redact: ["req.headers.authorization", "req.headers.cookie", "req.body.apiKey", "*.apiKey", "*.data"] }, genReqId: () => `req_${crypto.randomUUID()}` });
|
||||
await app.register(cors, { origin: false });
|
||||
const maxImageBytes = intEnv("MAX_IMAGE_BYTES", 5_242_880); const maxSpreadsheetBytes = intEnv("MAX_SPREADSHEET_BYTES", 20_971_520);
|
||||
await app.register(multipart, { limits: { files: 1, fileSize: Math.max(maxImageBytes, maxSpreadsheetBytes), fields: 2 } });
|
||||
const events = new MemoryEventStore(1000);
|
||||
const registryRef: { current?: MemorySessionRegistry } = {};
|
||||
const approvals = new MemoryApprovalBroker(intEnv("APPROVAL_TIMEOUT_MS", 60_000), (resolution) => {
|
||||
events.append(resolution.request.sessionId, { type: "approval.resolved", runId: resolution.request.runId, payload: { approvalId: resolution.request.approvalId, toolCallId: resolution.request.toolCallId, decision: resolution.decision } });
|
||||
}, (request) => events.append(request.sessionId, { type: "approval.required", runId: request.runId, payload: request }));
|
||||
const models = new MemoryModelConnectionStore(process.env.ALLOW_PRIVATE_LLM_BASE_URLS === "true", persistentModelPath);
|
||||
await models.loadPersistent().catch((error) => app.log.warn({ err: error }, "persistent model config could not be loaded"));
|
||||
const accountProfiles = new PersistentAccountProfileStore(persistentAccountProfilePath);
|
||||
await accountProfiles.load().catch((error) => app.log.warn({ err: error }, "persistent account profile could not be loaded"));
|
||||
const skills = await SkillCatalog.create(projectRoot, path.join(projectRoot, "workspace", ".pi-agent"));
|
||||
const attachments = new MemoryAttachmentStore((id) => sessionOrThrow(registryRef.current!, id).workspace, maxImageBytes, 40_000_000, maxSpreadsheetBytes);
|
||||
const sessions = new MemorySessionRegistry({ projectRoot, sessionsRoot: path.join(projectRoot, "workspace", "sessions"), agentDir: path.join(projectRoot, "workspace", ".pi-agent"), fixturePath: path.join(projectRoot, "fixtures", "ads-account.json"), eventStore: events, approvals, models, attachments, skills, getAccountProfile: () => accountProfiles.get(), runTimeoutMs: intEnv("SESSION_RUN_TIMEOUT_MS", 600_000), maxImages: intEnv("MAX_IMAGES_PER_MESSAGE", 4) });
|
||||
registryRef.current = sessions;
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
const harness = error instanceof HarnessError ? error : undefined;
|
||||
const validation = error instanceof z.ZodError;
|
||||
const frameworkStatus = (error as { statusCode?: number }).statusCode;
|
||||
const status = harness?.status ?? (validation ? 400 : frameworkStatus && frameworkStatus >= 400 && frameworkStatus < 600 ? frameworkStatus : 500);
|
||||
const code = harness?.code ?? (validation ? "VALIDATION_ERROR" : status === 413 ? "PAYLOAD_TOO_LARGE" : "INTERNAL_ERROR");
|
||||
const message = harness?.message ?? (status === 500 ? "Internal server error" : safeError(error));
|
||||
const details = validation ? { fields: error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })) } : harness?.details ?? {};
|
||||
if (status === 500) request.log.error({ err: error }, "request failed");
|
||||
void reply.status(status).send({ error: { code, message, requestId: request.id, details } });
|
||||
});
|
||||
|
||||
app.get("/health", async (request) => success(request, { status: "ok" }));
|
||||
app.get("/api/skills", async (request) => success(request, { skills: skills.list() }));
|
||||
app.get("/api/account-profile", async (request) => success(request, { profile: accountProfiles.get() ?? null }));
|
||||
app.put("/api/account-profile", async (request) => success(request, { profile: await accountProfiles.save(accountProfileSchema.parse(request.body)) }));
|
||||
app.post("/api/sessions", async (request, reply) => {
|
||||
const session = await sessions.create();
|
||||
const apiKey = process.env.AGENT_PROVIDER === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
|
||||
if (process.env.AGENT_PROVIDER && process.env.AGENT_MODEL && apiKey) await models.configure(session.sessionId, { providerId: process.env.AGENT_PROVIDER, api: modelConnectionInputSchema.shape.api.parse(process.env.LLM_API ?? "openai-responses"), ...(process.env.LLM_BASE_URL ? { baseUrl: process.env.LLM_BASE_URL } : {}), apiKey, modelId: process.env.AGENT_MODEL, supportsImages: false }, { persist: false, source: "environment" });
|
||||
else await models.applyPersistent(session.sessionId);
|
||||
return reply.status(201).send(success(request, { sessionId: session.sessionId }));
|
||||
});
|
||||
app.delete("/api/sessions/:sessionId", async (request) => { const { sessionId } = idParams.parse(request.params); await sessions.delete(sessionId); return success(request); });
|
||||
app.post("/api/sessions/:sessionId/messages", async (request, reply) => {
|
||||
const { sessionId } = idParams.parse(request.params); const input = messageInputSchema.parse(request.body); const runId = await sessionOrThrow(sessions, sessionId).start(input); return reply.status(202).send(success(request, { runId }));
|
||||
});
|
||||
app.post("/api/sessions/:sessionId/cancel", async (request) => { const { sessionId } = idParams.parse(request.params); await sessionOrThrow(sessions, sessionId).cancel("user"); return success(request); });
|
||||
|
||||
app.get("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = idParams.parse(request.params); sessionOrThrow(sessions, sessionId); return success(request, { model: models.getRedacted(sessionId) ?? { source: "missing", configured: false } }); });
|
||||
app.put("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = idParams.parse(request.params); const session = sessionOrThrow(sessions, sessionId); if (session.state === "running") throw new HarnessError("SESSION_BUSY", "Cannot change model while a run is active", 409); const input = modelConnectionInputSchema.parse(request.body); const model = await models.configure(sessionId, input); session.resetAgent(); events.append(sessionId, { type: "model.configured", payload: model }); return success(request, { model }); });
|
||||
app.delete("/api/sessions/:sessionId/model-config/credentials", async (request) => { const { sessionId } = idParams.parse(request.params); const session = sessionOrThrow(sessions, sessionId); if (session.state === "running") throw new HarnessError("SESSION_BUSY", "Cannot clear credentials while a run is active", 409); await models.clearCredentials(sessionId); return success(request, { model: models.getRedacted(sessionId) ?? { source: "missing", configured: false } }); });
|
||||
app.post("/api/sessions/:sessionId/model-config/test", async (request) => { const { sessionId } = idParams.parse(request.params); sessionOrThrow(sessions, sessionId); await models.createRuntime(sessionId); return success(request, { status: "deferred", message: "配置已通过本地验证,将在首次运行时验证连接" }); });
|
||||
|
||||
app.post("/api/sessions/:sessionId/attachments", async (request, reply) => { const { sessionId } = idParams.parse(request.params); sessionOrThrow(sessions, sessionId); const part = await request.file(); if (!part) throw new HarnessError("ATTACHMENT_REQUIRED", "Multipart attachment file is required", 400); try { const attachment = await attachments.add(sessionId, { filename: part.filename, mimetype: part.mimetype, stream: part.file }); const { sessionId: owner, ...payload } = attachment; void owner; events.append(sessionId, { type: "attachment.uploaded", payload }); return reply.status(201).send(success(request, { attachment })); } catch (error) { events.append(sessionId, { type: "attachment.rejected", payload: { name: part.filename, reason: error instanceof HarnessError ? error.code : "UPLOAD_FAILED" } }); throw error; } });
|
||||
app.delete("/api/sessions/:sessionId/attachments/:attachmentId", async (request) => { const { sessionId, attachmentId } = attachmentParams.parse(request.params); sessionOrThrow(sessions, sessionId); if (!attachments.get(sessionId, attachmentId)) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found", 404); await attachments.remove(sessionId, attachmentId); return success(request); });
|
||||
app.get("/api/sessions/:sessionId/artifacts/:filename", async (request, reply) => { const { sessionId, filename } = artifactParams.parse(request.params); const session = sessionOrThrow(sessions, sessionId); const file = await session.workspace.resolve(filename, true); try { const info = await stat(file); if (!info.isFile()) throw new Error("not a file"); } catch { throw new HarnessError("ARTIFACT_NOT_FOUND", "Word 报告尚未生成", 404); } const content = await readFile(file); return reply.header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document").header("Content-Disposition", `attachment; filename=${filename}; filename*=UTF-8''${filename}`).send(content); });
|
||||
|
||||
const resolveApproval = async (request: FastifyRequest, decision: "approve" | "deny") => { const { approvalId } = approvalParams.parse(request.params); const pending = approvals.get(approvalId); if (!pending) throw new HarnessError("APPROVAL_NOT_FOUND", "Approval not found", 404); const session = sessionOrThrow(sessions, pending.sessionId); if (session.currentRunId !== pending.runId) throw new HarnessError("APPROVAL_RUN_MISMATCH", "Approval does not belong to the active run", 409); const resolution = decision === "approve" ? approvals.approve(approvalId) : approvals.deny(approvalId); return success(request, { resolution }); };
|
||||
app.post("/api/approvals/:approvalId/approve", async (request) => resolveApproval(request, "approve"));
|
||||
app.post("/api/approvals/:approvalId/deny", async (request) => resolveApproval(request, "deny"));
|
||||
|
||||
app.get("/api/sessions/:sessionId/events", async (request, reply) => {
|
||||
const { sessionId } = idParams.parse(request.params); sessionOrThrow(sessions, sessionId);
|
||||
const query = z.object({ afterSequence: z.coerce.number().int().nonnegative().optional() }).parse(request.query);
|
||||
const lastId = request.headers["last-event-id"]; const history = events.listAfter(sessionId);
|
||||
let sequence = query.afterSequence ?? 0;
|
||||
if (query.afterSequence === undefined && typeof lastId === "string") sequence = history.find((event) => event.eventId === lastId)?.sequence ?? 0;
|
||||
reply.hijack(); reply.raw.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", "X-Request-Id": request.id });
|
||||
const write = (event: { eventId: string; type: string; sequence: number } & Record<string, unknown>) => reply.raw.write(`id: ${event.eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
const earliest = history.at(0)?.sequence ?? 1; if (sequence > 0 && sequence < earliest - 1) reply.raw.write(`event: harness.reset\ndata: ${JSON.stringify({ reason: "EVENT_HISTORY_GAP", earliestSequence: earliest })}\n\n`);
|
||||
for (const event of events.listAfter(sessionId, { sequence })) write(event);
|
||||
const unsubscribe = events.subscribe(sessionId, (event) => { if (!write(event)) { unsubscribe(); clearInterval(heartbeat); reply.raw.end(); } });
|
||||
const heartbeat = setInterval(() => reply.raw.write(`: heartbeat ${Date.now()}\n\n`), 15_000);
|
||||
request.raw.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
|
||||
});
|
||||
app.addHook("onClose", async () => { await Promise.all(sessions.list().map((session) => session.dispose())); });
|
||||
return { app, events, approvals, models, attachments, sessions, skills, accountProfiles };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "./app.js";
|
||||
|
||||
const { app } = await createApp();
|
||||
await app.listen({ port: Number(process.env.PORT ?? 3001), host: process.env.HOST ?? "127.0.0.1" });
|
||||
@@ -0,0 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { createApp, type AppContext } from "../src/app.js";
|
||||
const root=path.resolve(import.meta.dirname,"../../..");
|
||||
describe("Fastify API", () => { let context: AppContext; beforeEach(async()=>{context=await createApp(root)}); afterEach(async()=>{await context.app.close()});
|
||||
it("returns health, requestId and only the built-in skill", async()=>{ const health=await context.app.inject({method:"GET",url:"/health"}); expect(health.statusCode).toBe(200); expect(health.json().requestId).toMatch(/^req_/); const skills=await context.app.inject({method:"GET",url:"/api/skills"}); expect(skills.json().skills.map((skill:{name:string})=>skill.name)).toEqual(["daily-campaign-triage"]); });
|
||||
it("uses unified 404 and validation errors", async()=>{ const missing=await context.app.inject({method:"DELETE",url:"/api/sessions/nope"}); expect(missing.statusCode).toBe(404); expect(missing.json().error.code).toBe("SESSION_NOT_FOUND"); const created=(await context.app.inject({method:"POST",url:"/api/sessions"})).json(); const invalid=await context.app.inject({method:"POST",url:`/api/sessions/${created.sessionId}/messages`,payload:{text:"",attachmentIds:[]}}); expect(invalid.statusCode).toBe(400); expect(invalid.json().error.code).toBe("VALIDATION_ERROR"); });
|
||||
it("preserves framework 4xx status codes",async()=>{ const response=await context.app.inject({method:"POST",url:"/api/sessions",headers:{"content-type":"application/json"},payload:""}); expect(response.statusCode).toBe(400); });
|
||||
it("does not expose API keys in model config", async()=>{ const {sessionId}=(await context.app.inject({method:"POST",url:"/api/sessions"})).json(); const key="sk-training-super-secret"; const saved=await context.app.inject({method:"PUT",url:`/api/sessions/${sessionId}/model-config`,payload:{providerId:"openai",api:"openai-responses",apiKey:key,modelId:"gpt-5",supportsImages:true}}); expect(saved.statusCode).toBe(200); expect(saved.body).not.toContain(key); const get=await context.app.inject({method:"GET",url:`/api/sessions/${sessionId}/model-config`}); expect(get.body).not.toContain(key); expect(get.json().model.keyHint).toBe("••••cret"); });
|
||||
it("loads a saved model and key after an app restart",async()=>{ const temp=await mkdtemp(path.join(os.tmpdir(),"asm-api-model-")); const file=path.join(temp,"model.json"); const first=await createApp(root,file); const {sessionId}=(await first.app.inject({method:"POST",url:"/api/sessions"})).json(); await first.app.inject({method:"PUT",url:`/api/sessions/${sessionId}/model-config`,payload:{providerId:"openai",api:"openai-responses",apiKey:"sk-persist-across-restart",modelId:"gpt-5",supportsImages:false}}); await first.app.close(); const second=await createApp(root,file); const created=(await second.app.inject({method:"POST",url:"/api/sessions"})).json(); const restored=await second.app.inject({method:"GET",url:`/api/sessions/${created.sessionId}/model-config`}); expect(restored.json().model).toMatchObject({source:"persistent",configured:true,keyHint:"••••tart"}); await second.app.close(); });
|
||||
it("persists the confirmed account KPI profile across restarts",async()=>{ const temp=await mkdtemp(path.join(os.tmpdir(),"asm-api-profile-")); const file=path.join(temp,"account.json"); const profile={accountName:"蚂蚁",analysisDatePolicy:"yesterday",retentionLagDays:1,kpis:[{metric:"CPI",role:"primary",formula:"spend / installs",direction:"lower",target:"≤ 4",scope:"账户级",maturityRule:"当日完整"}],pacing:{enabled:true,targetValue:"600",currency:"USD",scope:"账户级",period:"每日",targetType:"cap"}}; const first=await createApp(root,undefined,file); const saved=await first.app.inject({method:"PUT",url:"/api/account-profile",payload:profile}); expect(saved.statusCode).toBe(200); await first.app.close(); const second=await createApp(root,undefined,file); const restored=await second.app.inject({method:"GET",url:"/api/account-profile"}); expect(restored.json().profile).toMatchObject({accountName:"蚂蚁",kpis:[{target:"≤ 4"}],pacing:{targetType:"cap"}}); await second.app.close(); });
|
||||
it("returns 202 semantics only after run acceptance and no model gives 422", async()=>{ const {sessionId}=(await context.app.inject({method:"POST",url:"/api/sessions"})).json(); const response=await context.app.inject({method:"POST",url:`/api/sessions/${sessionId}/messages`,payload:{text:"hello",attachmentIds:[]}}); expect(response.statusCode).toBe(422); expect(response.json().error.code).toBe("MODEL_NOT_CONFIGURED"); });
|
||||
it("downloads only a dated Skill Word artifact",async()=>{ const {sessionId}=(await context.app.inject({method:"POST",url:"/api/sessions"})).json(); const name="2026-09-15-campaign-triage.docx"; const missing=await context.app.inject({method:"GET",url:`/api/sessions/${sessionId}/artifacts/${name}`}); expect(missing.statusCode).toBe(404); const invalid=await context.app.inject({method:"GET",url:`/api/sessions/${sessionId}/artifacts/report.docx`}); expect(invalid.statusCode).toBe(400); const session=context.sessions.get(sessionId)!; await writeFile(await session.workspace.resolve(name,true),Buffer.from("PK-test")); const response=await context.app.inject({method:"GET",url:`/api/sessions/${sessionId}/artifacts/${name}`}); expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain("wordprocessingml.document"); expect(response.headers["content-disposition"]).toContain(name); });
|
||||
it("opens an SSE stream with correct headers", async()=>{ const address=await context.app.listen({host:"127.0.0.1",port:0}); const created=await fetch(`${address}/api/sessions`,{method:"POST"}).then((r)=>r.json()) as {sessionId:string}; const controller=new AbortController(); const response=await fetch(`${address}/api/sessions/${created.sessionId}/events`,{signal:controller.signal}); expect(response.headers.get("content-type")).toContain("text/event-stream"); controller.abort(); });
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>Agent Studio Mini</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@agent-studio/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "dev": "vite", "build": "vite build", "typecheck": "tsc -p tsconfig.json --noEmit" },
|
||||
"dependencies": {
|
||||
"@agent-studio/shared": "workspace:*", "@assistant-ui/react": "0.15.17", "lucide-react": "latest", "react": "latest", "react-dom": "latest", "zod": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AssistantRuntimeProvider, useAui } from "@assistant-ui/react";
|
||||
import {
|
||||
BarChart3,
|
||||
CircleHelp,
|
||||
MessageCircle,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import { DAILY_REPORT_PROMPT } from "@agent-studio/shared";
|
||||
import { HarnessClientStore } from "./assistant/HarnessClientStore.js";
|
||||
import { harnessAuiConfig, useHarnessRuntime } from "./assistant/useHarnessRuntime.js";
|
||||
import { ThreadView } from "./components/ThreadView.js";
|
||||
import { AnalysisProgress } from "./components/AnalysisProgress.js";
|
||||
import { ModelSettings } from "./components/ModelSettings.js";
|
||||
import { AccountSettings } from "./components/AccountSettings.js";
|
||||
import { ApprovalDialog } from "./components/ApprovalDialog.js";
|
||||
|
||||
function QuickRunButton({
|
||||
configured,
|
||||
accountName,
|
||||
running,
|
||||
onNeedsModel,
|
||||
onNeedsAccount,
|
||||
onArm
|
||||
}: {
|
||||
configured: boolean;
|
||||
accountName: string | undefined;
|
||||
running: boolean;
|
||||
onNeedsModel: () => void;
|
||||
onNeedsAccount: () => void;
|
||||
onArm: () => void;
|
||||
}) {
|
||||
const aui = useAui();
|
||||
const prepare = () => {
|
||||
if (!accountName) {
|
||||
onNeedsAccount();
|
||||
return;
|
||||
}
|
||||
if (!configured) {
|
||||
onNeedsModel();
|
||||
return;
|
||||
}
|
||||
onArm();
|
||||
aui.composer.setText(DAILY_REPORT_PROMPT);
|
||||
};
|
||||
const name = accountName?.replace(/账户$/, "");
|
||||
|
||||
return (
|
||||
<button className="quick-run" type="button" disabled={running} onClick={prepare}>
|
||||
<Play size={16} fill="currentColor" />
|
||||
{running ? `正在生成${name ? `${name}账户` : ""}日报` : name ? `生成${name}账户日报` : "先配置账户 KPI"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const store = useMemo(() => new HarnessClientStore(), []);
|
||||
const { runtime, snapshot } = useHarnessRuntime(store);
|
||||
const [modelSettings, setModelSettings] = useState(false);
|
||||
const [accountSettings, setAccountSettings] = useState(false);
|
||||
const [accountArmed, setAccountArmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void store.initialize().catch((error: unknown) => console.error(error));
|
||||
return () => store.dispose();
|
||||
}, [store]);
|
||||
|
||||
const reset = async () => {
|
||||
setAccountArmed(false);
|
||||
await store.reset();
|
||||
};
|
||||
|
||||
const visualState = snapshot.running ? "thinking" : snapshot.artifact ? "creative" : "idle";
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime} config={harnessAuiConfig}>
|
||||
<div className="app-shell" data-ai-state={visualState}>
|
||||
<div className="aurora-layer" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<aside className="nav-rail" aria-label="主导航">
|
||||
<nav className="nav-rail__nav">
|
||||
<button className="nav-rail__button is-active" type="button" aria-label="日报对话" title="日报对话">
|
||||
<MessageCircle size={19} />
|
||||
</button>
|
||||
<button className="nav-rail__button" type="button" onClick={() => setAccountSettings(true)} aria-label="账户 KPI" title="账户 KPI">
|
||||
<BarChart3 size={19} />
|
||||
</button>
|
||||
<button className="nav-rail__button" type="button" onClick={() => void reset()} aria-label="新建任务" title="新建任务">
|
||||
<RotateCcw size={19} />
|
||||
</button>
|
||||
<button className="nav-rail__button" type="button" onClick={() => setModelSettings(true)} aria-label="模型设置" title="模型设置">
|
||||
<Settings size={19} />
|
||||
</button>
|
||||
</nav>
|
||||
<span className="nav-rail__help" title="上传报表并生成日报">
|
||||
<CircleHelp size={19} />
|
||||
</span>
|
||||
</aside>
|
||||
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="brand-symbol"><Sparkles size={16} /></span>
|
||||
<div>
|
||||
<strong>Daily Campaign Agent</strong>
|
||||
<small>AURORA ANALYSIS STUDIO</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<QuickRunButton
|
||||
configured={snapshot.model.configured}
|
||||
accountName={snapshot.accountProfile?.accountName}
|
||||
running={snapshot.running}
|
||||
onNeedsModel={() => setModelSettings(true)}
|
||||
onNeedsAccount={() => setAccountSettings(true)}
|
||||
onArm={() => setAccountArmed(true)}
|
||||
/>
|
||||
<div className="session-meta">
|
||||
<span className={`connection ${snapshot.connected ? "online" : ""}`}>
|
||||
{snapshot.connected ? "服务已连接" : snapshot.reconnecting ? "正在重连" : "服务离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="workspace-grid">
|
||||
<ThreadView
|
||||
configured={snapshot.model.configured}
|
||||
accountProfile={accountArmed ? snapshot.accountProfile : undefined}
|
||||
artifact={snapshot.artifact}
|
||||
error={snapshot.error}
|
||||
running={snapshot.running}
|
||||
events={snapshot.events}
|
||||
/>
|
||||
<AnalysisProgress events={snapshot.events} running={snapshot.running} reconnecting={snapshot.reconnecting} />
|
||||
</main>
|
||||
|
||||
<div className="aurora-wave" aria-hidden="true">
|
||||
{Array.from({ length: 19 }, (_, index) => <i key={index} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accountSettings && <AccountSettings open onClose={() => setAccountSettings(false)} profile={snapshot.accountProfile} store={store} />}
|
||||
<ModelSettings open={modelSettings} onClose={() => setModelSettings(false)} model={snapshot.model} store={store} />
|
||||
<ApprovalDialog request={snapshot.pendingApproval} store={store} />
|
||||
<div className="sr-only" aria-live="polite">
|
||||
{snapshot.running ? "Agent 正在运行" : snapshot.artifact ? "Word 报告已可下载" : snapshot.events.at(-1)?.type === "assistant.completed" ? "Agent 回复完成" : ""}
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { AppendMessage, AttachmentAdapter, CompleteAttachment, PendingAttachment, ThreadMessageLike } from "@assistant-ui/react";
|
||||
import type { AccountProfile, ApprovalRequest, Attachment, HarnessEvent, RedactedModelConnection, ReportArtifact, SkillCatalogEntry } from "@agent-studio/shared";
|
||||
|
||||
type ToolState = { toolCallId: string; toolName: string; args?: unknown; result?: unknown; error?: string; approval?: ApprovalRequest; decision?: string };
|
||||
export type HarnessSnapshot = { sessionId: string | undefined; connected: boolean; reconnecting: boolean; running: boolean; messages: ThreadMessageLike[]; events: HarnessEvent[]; skills: SkillCatalogEntry[]; model: RedactedModelConnection; accountProfile: AccountProfile | undefined; tools: Map<string, ToolState>; pendingApproval: ApprovalRequest | undefined; artifact: ReportArtifact | undefined; error: string | undefined };
|
||||
const initial = (): HarnessSnapshot => ({ sessionId: undefined, connected: false, reconnecting: false, running: false, messages: [], events: [], skills: [], model: { source: "missing", configured: false }, accountProfile: undefined, tools: new Map(), pendingApproval: undefined, artifact: undefined, error: undefined });
|
||||
|
||||
export class HarnessClientStore {
|
||||
private state = initial(); private readonly listeners = new Set<() => void>(); private source?: EventSource; private seen = new Set<string>(); private lastSequence = 0; private readyAttachments: string[] = []; private objectUrls = new Map<string, string>(); private attachmentMetadata = new Map<string, Attachment>(); private initializing: Promise<void> | undefined; private recovering: Promise<string> | undefined;
|
||||
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
|
||||
getSnapshot = () => this.state;
|
||||
private update(patch: Partial<HarnessSnapshot>) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
|
||||
private async requestOnce<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
let response: Response;
|
||||
try { response = await fetch(url, { ...init, headers: { ...(init?.body !== undefined && !(init.body instanceof FormData) ? { "Content-Type": "application/json" } : {}), ...init?.headers } }); }
|
||||
catch (error) { if (error instanceof TypeError) throw new Error("无法连接后端服务,请确认 pnpm dev 中的 server 已启动(默认端口 3001)", { cause: error }); throw error; }
|
||||
const text = await response.text(); let body: ({ error?: { code?: string; message?: string } } & T) | undefined;
|
||||
try { body = text ? JSON.parse(text) as typeof body : undefined; } catch { body = undefined; }
|
||||
if (!response.ok) { const failure = new Error(body?.error?.message ?? `HTTP ${response.status}`) as Error & { code?: string }; if (body?.error?.code) failure.code = body.error.code; throw failure; }
|
||||
if (!body) throw new Error("后端返回了无法解析的响应");
|
||||
return body;
|
||||
}
|
||||
private async recoverSession(expiredSessionId: string): Promise<string> {
|
||||
if (this.state.sessionId !== expiredSessionId) return this.state.sessionId!;
|
||||
if (!this.recovering) this.recovering = (async () => {
|
||||
this.source?.close();
|
||||
const session = await this.requestOnce<{ sessionId: string }>("/api/sessions", { method: "POST" });
|
||||
const model = await this.requestOnce<{ model: RedactedModelConnection }>(`/api/sessions/${session.sessionId}/model-config`);
|
||||
this.seen.clear(); this.lastSequence = 0;
|
||||
this.update({ sessionId: session.sessionId, connected: false, reconnecting: true, running: false, messages: [], events: [], tools: new Map(), pendingApproval: undefined, artifact: undefined, error: undefined, model: model.model });
|
||||
this.connect();
|
||||
return session.sessionId;
|
||||
})().finally(() => { this.recovering = undefined; });
|
||||
return this.recovering;
|
||||
}
|
||||
async request<T>(url: string, init?: RequestInit, recover = true): Promise<T> {
|
||||
try { return await this.requestOnce<T>(url, init); }
|
||||
catch (error) {
|
||||
const sessionId = this.state.sessionId; const code = (error as { code?: string }).code;
|
||||
if (!recover || code !== "SESSION_NOT_FOUND" || !sessionId || !url.includes(`/api/sessions/${sessionId}`)) throw error;
|
||||
const replacement = await this.recoverSession(sessionId);
|
||||
return this.request<T>(url.replace(`/api/sessions/${sessionId}`, `/api/sessions/${replacement}`), init, false);
|
||||
}
|
||||
}
|
||||
async initialize() {
|
||||
if (!this.initializing) this.initializing = (async () => { const session = await this.request<{ sessionId: string }>("/api/sessions", { method: "POST" }); this.update({ sessionId: session.sessionId }); await Promise.all([this.loadSkills(), this.loadModel(), this.loadAccountProfile()]); this.connect(); })().finally(() => { this.initializing = undefined; });
|
||||
return this.initializing;
|
||||
}
|
||||
private async ensureSession(): Promise<string> {
|
||||
if (!this.state.sessionId) {
|
||||
try { await this.initialize(); } catch (error) { if (!this.state.sessionId) throw error; }
|
||||
}
|
||||
if (!this.state.sessionId) throw new Error("Session 创建失败,请刷新页面后重试");
|
||||
return this.state.sessionId;
|
||||
}
|
||||
async loadSkills() { const result = await this.request<{ skills: SkillCatalogEntry[] }>("/api/skills"); this.update({ skills: result.skills }); }
|
||||
async loadModel() { if (!this.state.sessionId) return; const result = await this.request<{ model: RedactedModelConnection }>(`/api/sessions/${this.state.sessionId}/model-config`); this.update({ model: result.model }); }
|
||||
async loadAccountProfile() { const result = await this.request<{ profile: AccountProfile | null }>("/api/account-profile"); this.update({ accountProfile: result.profile ?? undefined }); }
|
||||
async configureAccountProfile(input: AccountProfile) { const result = await this.request<{ profile: AccountProfile }>("/api/account-profile", { method: "PUT", body: JSON.stringify(input) }); this.update({ accountProfile: result.profile }); return result.profile; }
|
||||
connect() {
|
||||
if (!this.state.sessionId) return; this.source?.close(); this.update({ reconnecting: true });
|
||||
this.source = new EventSource(`/api/sessions/${this.state.sessionId}/events?afterSequence=${this.lastSequence}`);
|
||||
this.source.onopen = () => this.update({ connected: true, reconnecting: false });
|
||||
this.source.onerror = () => this.update({ connected: false, reconnecting: true });
|
||||
const types = ["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","artifact.ready","artifact.failed","model.configured","model.validation_failed","run.started","run.cancelled","run.completed","run.failed"];
|
||||
for (const type of types) this.source.addEventListener(type, (raw) => { try { this.reduce(JSON.parse((raw as MessageEvent<string>).data) as HarnessEvent); } catch { this.update({ error: "事件流解析失败" }); } });
|
||||
}
|
||||
private reduce(event: HarnessEvent) {
|
||||
if (this.seen.has(event.eventId) || event.sequence <= this.lastSequence) return; this.seen.add(event.eventId); this.lastSequence = event.sequence;
|
||||
const events = [...this.state.events, event].slice(-1000); const messages = [...this.state.messages]; const tools = new Map(this.state.tools); let pendingApproval = this.state.pendingApproval;
|
||||
if (event.type === "user.message") messages.push({ id: `user_${event.runId}`, role: "user", content: event.payload.text || "(附件消息)", createdAt: new Date(event.timestamp), attachments: event.payload.attachmentIds.map((id) => { const metadata = this.attachmentMetadata.get(id); const name = metadata?.name ?? id; return metadata?.kind === "spreadsheet" ? { id, type: "document" as const, name, contentType: metadata.mediaType, status: { type: "complete" as const }, content: [{ type: "file" as const, filename: name, mimeType: metadata.mediaType, data: id, sourceType: "id" as const }] } : { id, type: "image" as const, name, contentType: metadata?.mediaType, status: { type: "complete" as const }, content: [{ type: "image" as const, image: this.objectUrls.get(id) ?? "", filename: name }] }; }) });
|
||||
if (event.type === "assistant.delta") { const id = event.payload.messageId; const index = messages.findIndex((item) => item.id === id); if (index < 0) messages.push({ id, role: "assistant", content: event.payload.delta, status: { type: "running" } }); else { const old = messages[index]!; const text = typeof old.content === "string" ? old.content : ""; messages[index] = { ...old, content: text + event.payload.delta, status: { type: "running" } }; } }
|
||||
if (event.type === "assistant.completed") { const index = messages.findIndex((item) => item.id === event.payload.messageId); if (index >= 0) messages[index] = { ...messages[index]!, status: { type: "complete", reason: "stop" } }; }
|
||||
if (event.type === "tool.requested") { tools.set(event.payload.toolCallId, { toolCallId: event.payload.toolCallId, toolName: event.payload.toolName, args: event.payload.arguments }); messages.push({ id: `tool_${event.payload.toolCallId}`, role: "assistant", content: [{ type: "tool-call", toolCallId: event.payload.toolCallId, toolName: event.payload.toolName, args: (event.payload.arguments && typeof event.payload.arguments === "object" ? event.payload.arguments : {}) as Record<string, never> }], status: { type: "running" } }); }
|
||||
if (event.type === "tool.completed" || event.type === "tool.failed") { const tool = tools.get(event.payload.toolCallId); if (tool) tools.set(event.payload.toolCallId, { ...tool, ...(event.type === "tool.failed" ? { error: event.payload.error ?? "Tool failed" } : { result: event.payload.result }) }); const index = messages.findIndex((item) => item.id === `tool_${event.payload.toolCallId}`); if (index >= 0) messages[index] = { ...messages[index]!, content: [{ type: "tool-call", toolCallId: event.payload.toolCallId, toolName: event.payload.toolName, args: (tool?.args && typeof tool.args === "object" ? tool.args : {}) as Record<string, never>, ...(event.type === "tool.failed" ? { result: event.payload.error, isError: true } : { result: event.payload.result }) }], status: { type: "complete", reason: "stop" } }; }
|
||||
if (event.type === "approval.required") { pendingApproval = event.payload; const tool = tools.get(event.payload.toolCallId); if (tool) tools.set(event.payload.toolCallId, { ...tool, approval: event.payload }); }
|
||||
if (event.type === "approval.resolved") { if (pendingApproval?.approvalId === event.payload.approvalId) pendingApproval = undefined; const tool = tools.get(event.payload.toolCallId); if (tool) tools.set(event.payload.toolCallId, { ...tool, decision: event.payload.decision }); }
|
||||
if (event.type === "model.configured") this.state.model = event.payload;
|
||||
let error = this.state.error; let artifact = this.state.artifact;
|
||||
if (event.type === "attachment.rejected") { const reasons: Record<string,string> = { ATTACHMENT_TYPE_REJECTED:"不支持此附件格式;Excel 请使用 .xlsx", ATTACHMENT_TOO_LARGE:"附件超过大小限制", ATTACHMENT_MAGIC_MISMATCH:"附件内容与扩展名不一致", ATTACHMENT_ENCODING_REJECTED:"CSV/TSV 必须使用 UTF-8 编码" }; error = `附件 ${event.payload.name ?? ""} 上传失败:${reasons[event.payload.reason] ?? event.payload.reason}`; }
|
||||
if (event.type === "attachment.uploaded" || event.type === "run.started") error = undefined;
|
||||
if (event.type === "artifact.ready") { artifact = event.payload; error = undefined; }
|
||||
if (event.type === "artifact.failed") error = `Word 生成失败:${event.payload.message}`;
|
||||
if (event.type === "run.failed") messages.push({ id: `error_${event.runId ?? event.eventId}`, role: "assistant", content: `运行失败:${event.payload.message}`, status: { type: "complete", reason: "unknown" } });
|
||||
const running = event.type === "run.started" ? true : ["run.completed","run.failed","run.cancelled"].includes(event.type) ? false : this.state.running;
|
||||
this.update({ events, messages, tools, pendingApproval, running, artifact, error: event.type === "run.failed" ? event.payload.message : error });
|
||||
}
|
||||
onNew = async (message: AppendMessage) => { const sessionId = await this.ensureSession(); const text = message.content.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text").map((part) => part.text).join("\n"); const attachmentIds = this.readyAttachments.splice(0); await this.request(`/api/sessions/${sessionId}/messages`, { method: "POST", body: JSON.stringify({ text, attachmentIds }) }); };
|
||||
cancel = async () => { const sessionId = await this.ensureSession(); await this.request(`/api/sessions/${sessionId}/cancel`, { method: "POST" }); };
|
||||
resolveApproval = async (id: string, decision: "approve" | "deny") => { await this.request(`/api/approvals/${id}/${decision}`, { method: "POST" }); };
|
||||
configureModel = async (input: unknown) => { const sessionId = await this.ensureSession(); const result = await this.request<{ model: RedactedModelConnection }>(`/api/sessions/${sessionId}/model-config`, { method: "PUT", body: JSON.stringify(input) }); this.update({ model: result.model }); };
|
||||
clearCredentials = async () => { const sessionId = await this.ensureSession(); const result = await this.request<{ model: RedactedModelConnection }>(`/api/sessions/${sessionId}/model-config/credentials`, { method: "DELETE" }); this.update({ model: result.model }); };
|
||||
testModel = async () => { const sessionId = await this.ensureSession(); return this.request<{ message: string }>(`/api/sessions/${sessionId}/model-config/test`, { method: "POST" }); };
|
||||
reset = async () => { if (this.state.sessionId) await this.request(`/api/sessions/${this.state.sessionId}`, { method: "DELETE" }); this.source?.close(); for (const url of this.objectUrls.values()) URL.revokeObjectURL(url); this.objectUrls.clear(); this.attachmentMetadata.clear(); this.seen.clear(); this.lastSequence = 0; this.state = initial(); await this.initialize(); };
|
||||
attachmentAdapter: AttachmentAdapter = {
|
||||
accept: "image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp,.xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values",
|
||||
add: async ({ file }): Promise<PendingAttachment> => { const document = /\.(xlsx|csv|tsv)$/i.test(file.name); return { id: crypto.randomUUID(), type: document ? "document" : "image", name: file.name, contentType: file.type, file, status: { type: "requires-action", reason: "composer-send" } }; },
|
||||
send: async (attachment): Promise<CompleteAttachment> => { const sessionId = await this.ensureSession(); const form = new FormData(); form.append("file", attachment.file); let result: { attachment: Attachment }; try { result = await this.request<{ attachment: Attachment }>(`/api/sessions/${sessionId}/attachments`, { method: "POST", body: form }); } catch (error) { this.update({ error: error instanceof Error ? `附件上传失败:${error.message}` : "附件上传失败" }); throw error; } this.attachmentMetadata.set(result.attachment.attachmentId, result.attachment); this.readyAttachments.push(result.attachment.attachmentId); if (result.attachment.kind === "spreadsheet") return { id: result.attachment.attachmentId, type: "document", name: result.attachment.name, contentType: result.attachment.mediaType, status: { type: "complete" }, content: [{ type: "file", filename: result.attachment.name, mimeType: result.attachment.mediaType, data: result.attachment.attachmentId, sourceType: "id" }] }; const url = URL.createObjectURL(attachment.file); this.objectUrls.set(result.attachment.attachmentId, url); return { id: result.attachment.attachmentId, type: "image", name: result.attachment.name, contentType: result.attachment.mediaType, status: { type: "complete" }, content: [{ type: "image", image: url, filename: result.attachment.name }] }; },
|
||||
remove: async (attachment) => { const sessionId = await this.ensureSession(); if (attachment.id.startsWith("att_")) await this.request(`/api/sessions/${sessionId}/attachments/${attachment.id}`, { method: "DELETE" }); const url = this.objectUrls.get(attachment.id); if (url) { URL.revokeObjectURL(url); this.objectUrls.delete(attachment.id); } this.attachmentMetadata.delete(attachment.id); this.readyAttachments = this.readyAttachments.filter((id) => id !== attachment.id); }
|
||||
};
|
||||
dispose() { this.source?.close(); for (const url of this.objectUrls.values()) URL.revokeObjectURL(url); this.attachmentMetadata.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { AuiConfig, Tools, defineToolkit, useExternalStoreRuntime, type ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import type { HarnessClientStore } from "./HarnessClientStore.js";
|
||||
|
||||
const AdsTool: ToolCallMessagePartComponent<Record<string, unknown>, unknown> = ({ args, result, status }) => <section className="tool-card" aria-label="广告指标工具"><div className="tool-card__head"><span>mock_ads_metrics</span><span className="status-dot">{status.type}</span></div><p>账户:{String(args.accountId ?? "等待参数")}</p><p>范围:最近 {String(args.days ?? "–")} 天</p>{result !== undefined && <pre>{JSON.stringify(result, null, 2).slice(0, 800)}</pre>}</section>;
|
||||
const AnalyzeReportTool: ToolCallMessagePartComponent<Record<string, unknown>, unknown> = ({ args, result, status }) => <section className="tool-card" aria-label="表格日报分析工具"><div className="tool-card__head"><span>analyze_uploaded_report</span><span>{status.type}</span></div><p>正在解析上传的 Excel/CSV 日报</p><p className="muted">附件:{String(args.attachmentId ?? "等待附件")}</p>{result !== undefined && <p>表格证据已生成,Agent 正在形成日报结论。</p>}</section>;
|
||||
const BuildBriefTool: ToolCallMessagePartComponent<Record<string, unknown>, unknown> = ({ args, result, status }) => <section className="tool-card" aria-label="Skill 日报模板生成工具"><div className="tool-card__head"><span>build_daily_brief</span><span>{status.type}</span></div><p>正在套用 daily-campaign-triage 固定四段式模板</p><p className="muted">日报日期:{String(args.analysis_date ?? "等待日期")}</p>{result !== undefined && <p>Skill 模板 Word 已生成,可点击下载。</p>}</section>;
|
||||
export const toolkit = defineToolkit({ mock_ads_metrics: { type: "backend", display: "standalone", render: AdsTool }, analyze_uploaded_report: { type: "backend", display: "standalone", render: AnalyzeReportTool }, build_daily_brief: { type: "backend", display: "standalone", render: BuildBriefTool } });
|
||||
export const harnessAuiConfig = AuiConfig({ tools: Tools({ toolkit }) });
|
||||
|
||||
export function useHarnessRuntime(store: HarnessClientStore) {
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
const runtime = useExternalStoreRuntime({ messages: snapshot.messages, convertMessage: (message) => message, isRunning: snapshot.running, isSendDisabled: !snapshot.model.configured, onNew: store.onNew, onCancel: store.cancel, adapters: { attachments: store.attachmentAdapter } });
|
||||
return useMemo(() => ({ runtime, snapshot }), [runtime, snapshot]);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { AccountProfile } from "@agent-studio/shared";
|
||||
import type { HarnessClientStore } from "../assistant/HarnessClientStore.js";
|
||||
|
||||
type Kpi = AccountProfile["kpis"][number];
|
||||
const emptyKpi = (): Kpi => ({ metric: "", role: "primary", formula: "", direction: "lower", target: "", scope: "账户级", maturityRule: "" });
|
||||
const emptyProfile = (): AccountProfile => ({ accountName: "", analysisDatePolicy: "yesterday", retentionLagDays: 1, kpis: [emptyKpi()], pacing: { enabled: false } });
|
||||
const clean = (value: string) => value.trim() || undefined;
|
||||
|
||||
export function AccountSettings({ open, onClose, profile, store }: { open: boolean; onClose: () => void; profile: AccountProfile | undefined; store: HarnessClientStore }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [draft, setDraft] = useState<AccountProfile>(() => profile ?? emptyProfile());
|
||||
const [message, setMessage] = useState("");
|
||||
useEffect(() => { if (open && !dialog.current?.open) dialog.current?.showModal(); else if (!open && dialog.current?.open) dialog.current.close(); }, [open]);
|
||||
const updateKpi = (index: number, patch: Partial<Kpi>) => setDraft((current) => ({ ...current, kpis: current.kpis.map((item, i) => i === index ? { ...item, ...patch } : item) }));
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault(); setMessage("保存中…");
|
||||
const normalized: AccountProfile = { ...draft, minimumSampleRule: clean(draft.minimumSampleRule ?? ""), budgetAdjustmentRule: clean(draft.budgetAdjustmentRule ?? ""), standardTestBudget: clean(draft.standardTestBudget ?? ""), timezone: clean(draft.timezone ?? ""), platformConstraints: clean(draft.platformConstraints ?? ""), kpis: draft.kpis.map((kpi) => ({ ...kpi, attribution: clean(kpi.attribution ?? ""), minimumSample: clean(kpi.minimumSample ?? "") })), pacing: { ...draft.pacing, targetValue: clean(draft.pacing.targetValue ?? ""), currency: clean(draft.pacing.currency ?? ""), scope: clean(draft.pacing.scope ?? ""), period: clean(draft.pacing.period ?? ""), allocationRule: clean(draft.pacing.allocationRule ?? "") } };
|
||||
try { await store.configureAccountProfile(normalized); setMessage("账户口径已持久保存,后续日报会自动使用"); }
|
||||
catch (error) { setMessage(error instanceof Error ? error.message : "保存失败"); }
|
||||
};
|
||||
return <dialog ref={dialog} className="settings-dialog account-dialog" onClose={onClose} onCancel={(event) => { event.preventDefault(); onClose(); }}>
|
||||
<form onSubmit={submit}>
|
||||
<div className="dialog-head"><div><small>PERSISTENT REPORT PROFILE</small><h2>账户日报配置</h2></div><button type="button" className="icon-button" onClick={onClose} aria-label="关闭设置">×</button></div>
|
||||
<p className="muted">这些口径会保存在本机服务端。只要报表不冲突,Agent 不会每天重复询问。</p>
|
||||
<section className="profile-section"><h3>账户与分析日期</h3><div className="form-grid">
|
||||
<label>账户名称<input value={draft.accountName} onChange={(e) => setDraft({ ...draft, accountName: e.target.value })} placeholder="例如:蚂蚁" required /></label>
|
||||
<label>日报日期<select value={draft.analysisDatePolicy} onChange={(e) => setDraft({ ...draft, analysisDatePolicy: e.target.value as AccountProfile["analysisDatePolicy"] })}><option value="yesterday">固定分析昨天</option><option value="latest_complete">使用报表最新完整日</option></select></label>
|
||||
<label>留存成熟滞后天数<input type="number" min="0" max="30" value={draft.retentionLagDays} onChange={(e) => setDraft({ ...draft, retentionLagDays: Number(e.target.value) })} required /></label>
|
||||
<label>时区(可选)<input value={draft.timezone ?? ""} onChange={(e) => setDraft({ ...draft, timezone: e.target.value })} placeholder="例如:Asia/Shanghai" /></label>
|
||||
</div></section>
|
||||
<section className="profile-section"><div className="profile-section__head"><div><h3>KPI 卡</h3><p>每个 KPI 都要填写公式、方向、目标、范围与成熟规则。</p></div><button type="button" className="button" onClick={() => setDraft({ ...draft, kpis: [...draft.kpis, emptyKpi()] })}><Plus size={15}/>添加 KPI</button></div>
|
||||
<div className="kpi-list">{draft.kpis.map((kpi, index) => <article className="kpi-card" key={index}><div className="kpi-card__head"><strong>KPI {index + 1}</strong><button type="button" className="icon-button" disabled={draft.kpis.length === 1} onClick={() => setDraft({ ...draft, kpis: draft.kpis.filter((_, i) => i !== index) })} aria-label={`删除 KPI ${index + 1}`}><Trash2 size={15}/></button></div><div className="form-grid form-grid--kpi">
|
||||
<label>指标<input value={kpi.metric} onChange={(e) => updateKpi(index, { metric: e.target.value })} placeholder="例如:CPI" required /></label>
|
||||
<label>角色<select value={kpi.role} onChange={(e) => updateKpi(index, { role: e.target.value as Kpi["role"] })}><option value="primary">主要 KPI</option><option value="secondary">次要 KPI</option><option value="guardrail">硬性护栏</option></select></label>
|
||||
<label>方向<select value={kpi.direction} onChange={(e) => updateKpi(index, { direction: e.target.value as Kpi["direction"] })}><option value="lower">越低越好</option><option value="higher">越高越好</option><option value="range">目标区间</option></select></label>
|
||||
<label>目标值<input value={kpi.target} onChange={(e) => updateKpi(index, { target: e.target.value })} placeholder="例如:≤ 4" required /></label>
|
||||
<label className="wide">计算公式<input value={kpi.formula} onChange={(e) => updateKpi(index, { formula: e.target.value })} placeholder="例如:消耗 ÷ 安装数" required /></label>
|
||||
<label>适用范围<input value={kpi.scope} onChange={(e) => updateKpi(index, { scope: e.target.value })} placeholder="账户级 / 指定产品" required /></label>
|
||||
<label>成熟规则<input value={kpi.maturityRule} onChange={(e) => updateKpi(index, { maturityRule: e.target.value })} placeholder="例如:D1 数据次日成熟" required /></label>
|
||||
<label>归因口径(可选)<input value={kpi.attribution ?? ""} onChange={(e) => updateKpi(index, { attribution: e.target.value })} placeholder="点击/浏览窗口与事件定义" /></label>
|
||||
<label>最低样本(可选)<input value={kpi.minimumSample ?? ""} onChange={(e) => updateKpi(index, { minimumSample: e.target.value })} placeholder="例如:安装 ≥ 20" /></label>
|
||||
</div></article>)}</div>
|
||||
</section>
|
||||
<section className="profile-section"><label className="checkbox pacing-toggle"><input type="checkbox" checked={draft.pacing.enabled} onChange={(e) => setDraft({ ...draft, pacing: { ...draft.pacing, enabled: e.target.checked } })}/> 启用日消耗与扩量判断</label>{draft.pacing.enabled && <div className="form-grid">
|
||||
<label>日消耗目标<input value={draft.pacing.targetValue ?? ""} onChange={(e) => setDraft({ ...draft, pacing: { ...draft.pacing, targetValue: e.target.value } })} placeholder="例如:600" required /></label>
|
||||
<label>币种<input value={draft.pacing.currency ?? ""} onChange={(e) => setDraft({ ...draft, pacing: { ...draft.pacing, currency: e.target.value } })} placeholder="例如:USD" required /></label>
|
||||
<label>作用范围<input value={draft.pacing.scope ?? ""} onChange={(e) => setDraft({ ...draft, pacing: { ...draft.pacing, scope: e.target.value } })} placeholder="例如:账户级" required /></label>
|
||||
<label>周期<input value={draft.pacing.period ?? ""} onChange={(e) => setDraft({ ...draft, pacing: { ...draft.pacing, period: e.target.value } })} placeholder="例如:每日" required /></label>
|
||||
<label>目标类型<select value={draft.pacing.targetType ?? ""} onChange={(e) => setDraft({ ...draft, pacing: { ...draft.pacing, targetType: e.target.value as NonNullable<AccountProfile["pacing"]["targetType"]> } })} required><option value="" disabled>请选择</option><option value="minimum">最低</option><option value="desired">期望</option><option value="cap">上限</option><option value="range">区间</option></select></label>
|
||||
<label>增量分配规则(可选)<input value={draft.pacing.allocationRule ?? ""} onChange={(e) => setDraft({ ...draft, pacing: { ...draft.pacing, allocationRule: e.target.value } })} placeholder="允许向哪些 Ads 增加消耗" /></label>
|
||||
</div>}</section>
|
||||
<section className="profile-section"><h3>长期规则(可选)</h3><div className="form-grid">
|
||||
<label>统一最低样本/持续性规则<input value={draft.minimumSampleRule ?? ""} onChange={(e) => setDraft({ ...draft, minimumSampleRule: e.target.value })} /></label>
|
||||
<label>预算调整规则<input value={draft.budgetAdjustmentRule ?? ""} onChange={(e) => setDraft({ ...draft, budgetAdjustmentRule: e.target.value })} /></label>
|
||||
<label>新 Ad 标准测试预算<input value={draft.standardTestBudget ?? ""} onChange={(e) => setDraft({ ...draft, standardTestBudget: e.target.value })} /></label>
|
||||
<label>平台限制<input value={draft.platformConstraints ?? ""} onChange={(e) => setDraft({ ...draft, platformConstraints: e.target.value })} placeholder="例如:只能在广告组层级改预算" /></label>
|
||||
</div></section>
|
||||
{message && <p className="form-message" role="status">{message}</p>}<div className="dialog-actions"><button type="button" className="button" onClick={onClose}>取消</button><button className="button button--primary">保存账户配置</button></div>
|
||||
</form>
|
||||
</dialog>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Check, Circle, FileDown, FileSpreadsheet, LoaderCircle, MessageSquareText, Sparkles, TriangleAlert } from "lucide-react";
|
||||
import type { HarnessEvent } from "@agent-studio/shared";
|
||||
import { currentRunEvents } from "./analysisProgressState.js";
|
||||
|
||||
type StageStatus = "pending" | "running" | "complete" | "error";
|
||||
|
||||
const eventLabel: Partial<Record<HarnessEvent["type"], string>> = {
|
||||
"attachment.uploaded": "报表上传完成",
|
||||
"skill.requested": "正在读取日报分析规则",
|
||||
"skill.loaded": "日报分析规则已加载",
|
||||
"tool.requested": "开始解析广告数据",
|
||||
"tool.completed": "数据解析完成",
|
||||
"tool.failed": "数据解析失败",
|
||||
"assistant.delta": "正在撰写日报结论",
|
||||
"assistant.completed": "日报内容生成完成",
|
||||
"artifact.ready": "Word 报告已生成",
|
||||
"artifact.failed": "Word 报告生成失败",
|
||||
"run.completed": "本轮任务已完成",
|
||||
"run.failed": "本轮任务失败"
|
||||
};
|
||||
|
||||
function getEventLabel(event: HarnessEvent) {
|
||||
if (event.type === "tool.requested" && event.payload.toolName === "build_daily_brief") return "正在套用 Skill 日报模板";
|
||||
if (event.type === "tool.completed" && event.payload.toolName === "build_daily_brief") return "Skill 模板 Word 生成完成";
|
||||
if (event.type === "tool.failed" && event.payload.toolName === "build_daily_brief") return "Skill 模板 Word 生成失败";
|
||||
return eventLabel[event.type];
|
||||
}
|
||||
|
||||
function has(events: HarnessEvent[], type: HarnessEvent["type"], predicate?: (event: HarnessEvent) => boolean) {
|
||||
return events.some((event) => event.type === type && (!predicate || predicate(event)));
|
||||
}
|
||||
|
||||
function stageStatus(events: HarnessEvent[], stage: "upload" | "skill" | "analysis" | "report" | "word"): StageStatus {
|
||||
const runEvents = currentRunEvents(events);
|
||||
const failed = has(runEvents, "run.failed");
|
||||
if (stage === "upload") return has(events, "attachment.uploaded") ? "complete" : runEvents.length ? "running" : "pending";
|
||||
if (stage === "skill") return has(events, "skill.loaded") ? "complete" : has(runEvents, "skill.requested") ? "running" : failed ? "error" : "pending";
|
||||
if (stage === "analysis") {
|
||||
if (has(runEvents, "tool.failed", (event) => event.type === "tool.failed" && event.payload.toolName === "analyze_uploaded_report")) return "error";
|
||||
if (has(runEvents, "tool.completed", (event) => event.type === "tool.completed" && event.payload.toolName === "analyze_uploaded_report")) return "complete";
|
||||
return has(runEvents, "tool.requested", (event) => event.type === "tool.requested" && event.payload.toolName === "analyze_uploaded_report") ? "running" : "pending";
|
||||
}
|
||||
if (stage === "report") return has(runEvents, "tool.requested", (event) => event.type === "tool.requested" && event.payload.toolName === "build_daily_brief") ? "complete" : has(runEvents, "assistant.delta") || has(runEvents, "tool.completed", (event) => event.type === "tool.completed" && event.payload.toolName === "analyze_uploaded_report") ? "running" : "pending";
|
||||
if (has(runEvents, "artifact.failed")) return "error";
|
||||
if (has(runEvents, "artifact.ready")) return "complete";
|
||||
return has(runEvents, "tool.requested", (event) => event.type === "tool.requested" && event.payload.toolName === "build_daily_brief") ? "running" : "pending";
|
||||
}
|
||||
|
||||
const stages = [
|
||||
{ id: "upload" as const, label: "接收广告报表", detail: "Excel / CSV 数据", Icon: FileSpreadsheet },
|
||||
{ id: "skill" as const, label: "加载分析规则", detail: "daily-campaign-triage", Icon: Sparkles },
|
||||
{ id: "analysis" as const, label: "解析与计算", detail: "指标、异常与趋势", Icon: LoaderCircle },
|
||||
{ id: "report" as const, label: "生成日报", detail: "结论与行动建议", Icon: MessageSquareText },
|
||||
{ id: "word" as const, label: "输出 Word", detail: "可下载报告文件", Icon: FileDown }
|
||||
];
|
||||
|
||||
export function AnalysisProgress({ events, running, reconnecting }: { events: HarnessEvent[]; running: boolean; reconnecting: boolean }) {
|
||||
const recent = events.filter((event) => getEventLabel(event)).slice(-6).reverse();
|
||||
return <aside className="side-panel progress-panel" aria-label="分析任务进度">
|
||||
<div className="panel-heading"><span>分析任务</span><i className={running ? "is-running" : ""}>{reconnecting ? "重连中" : running ? "运行中" : "就绪"}</i></div>
|
||||
<div className="progress-summary">
|
||||
<span className="progress-summary__eyebrow">唯一内置能力</span>
|
||||
<strong>Daily Campaign Triage</strong>
|
||||
<p>自动读取广告报表,识别异常并输出日报。</p>
|
||||
</div>
|
||||
<ol className="stage-list">
|
||||
{stages.map(({ id, label, detail, Icon }) => { const status = stageStatus(events, id); return <li key={id} className={`stage stage--${status}`}>
|
||||
<span className="stage__icon"><Icon size={16}/></span>
|
||||
<div><strong>{label}</strong><small>{detail}</small></div>
|
||||
<span className="stage__state" aria-label={status}>{status === "complete" ? <Check size={14}/> : status === "running" ? <LoaderCircle size={14}/> : status === "error" ? <TriangleAlert size={14}/> : <Circle size={9}/>}</span>
|
||||
</li>; })}
|
||||
</ol>
|
||||
<div className="task-log"><h3>最近动态</h3>{recent.length === 0 ? <p className="panel-state">上传报表后,过程会显示在这里。</p> : <ol>{recent.map((event) => <li key={event.eventId}><span/><div><strong>{getEventLabel(event)}</strong><time>{new Date(event.timestamp).toLocaleTimeString("zh-CN", { hour12: false })}</time></div></li>)}</ol>}</div>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApprovalRequest } from "@agent-studio/shared";
|
||||
import type { HarnessClientStore } from "../assistant/HarnessClientStore.js";
|
||||
|
||||
export function ApprovalDialog({ request, store }: { request: ApprovalRequest | undefined; store: HarnessClientStore }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
useEffect(() => {
|
||||
if (request && !dialog.current?.open) dialog.current?.showModal();
|
||||
if (!request && dialog.current?.open) dialog.current.close();
|
||||
}, [request]);
|
||||
if (!request) return null;
|
||||
const resolve = async (decision: "approve" | "deny") => { setSubmitting(true); try { await store.resolveApproval(request.approvalId, decision); } finally { setSubmitting(false); } };
|
||||
return <dialog ref={dialog} className="approval-dialog" onCancel={(event) => event.preventDefault()}><div className="approval-kicker">APPROVAL REQUIRED · {request.riskLevel.toUpperCase()}</div><h2>允许保存报告草稿?</h2><p>Agent 请求执行 <code>{request.toolName}</code>,将只写入当前 Session 的固定 <code>report.md</code>。</p><pre>{JSON.stringify(request.arguments, null, 2).slice(0, 1200)}</pre><div className="dialog-actions"><button className="button" disabled={submitting} onClick={() => void resolve("deny")}>拒绝</button><button className="button button--danger" disabled={submitting} onClick={() => void resolve("approve")}>批准并继续</button></div></dialog>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { HarnessEvent } from "@agent-studio/shared";
|
||||
type Filter = "all" | "run" | "skill" | "tool" | "approval";
|
||||
export function EventTimeline({ events, reconnecting }: { events: HarnessEvent[]; reconnecting: boolean }) { const [filter, setFilter] = useState<Filter>("all"); const visible = useMemo(() => events.filter((e) => filter === "all" || e.type.startsWith(`${filter}.`)).slice().reverse(), [events, filter]); return <aside className="side-panel events-panel"><div className="panel-heading"><span>EVENT TIMELINE</span><i>{reconnecting ? "重连中" : "LIVE"}</i></div><div className="filters" role="group" aria-label="事件筛选">{(["all","run","skill","tool","approval"] as const).map((item) => <button key={item} className={filter === item ? "active" : ""} onClick={() => setFilter(item)}>{item}</button>)}</div><ol className="timeline">{visible.length === 0 && <li className="panel-state">等待 Harness Event…</li>}{visible.map((event) => <li key={event.eventId}><span className="sequence">#{event.sequence}</span><div><strong>{event.type}</strong><time>{new Date(event.timestamp).toLocaleTimeString()}</time><details><summary>payload</summary><pre>{JSON.stringify(event.payload, null, 2)}</pre></details></div></li>)}</ol></aside>; }
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import type { RedactedModelConnection } from "@agent-studio/shared";
|
||||
import type { HarnessClientStore } from "../assistant/HarnessClientStore.js";
|
||||
export function ModelSettings({ open, onClose, model, store }: { open: boolean; onClose: () => void; model: RedactedModelConnection; store: HarnessClientStore }) { const dialog = useRef<HTMLDialogElement>(null); const [showKey, setShowKey] = useState(false); const [message, setMessage] = useState(""); useEffect(() => { if (open && !dialog.current?.open) dialog.current?.showModal(); if (!open && dialog.current?.open) dialog.current.close(); }, [open]); const configure = async (element: HTMLFormElement) => { const form = new FormData(element); await store.configureModel({ providerId: String(form.get("providerId")), api: String(form.get("api")), baseUrl: String(form.get("baseUrl") || "") || undefined, apiKey: String(form.get("apiKey") || "") || undefined, modelId: String(form.get("modelId")), displayName: String(form.get("displayName") || "") || undefined, supportsImages: form.get("supportsImages") === "on" }); }; const submit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setMessage("保存中…"); try { await configure(event.currentTarget); setMessage("已持久保存,重启后仍会自动加载"); } catch (error) { setMessage(error instanceof Error ? error.message : "保存失败"); } }; return <dialog ref={dialog} className="settings-dialog" onClose={onClose} onCancel={(e) => { e.preventDefault(); onClose(); }}><form onSubmit={submit}><div className="dialog-head"><div><small>PERSISTENT MODEL RUNTIME</small><h2>模型连接</h2></div><button type="button" className="icon-button" onClick={onClose} aria-label="关闭设置">×</button></div><p className="muted">来源:{model.source} · 密钥:{model.configured ? `已配置 ${model.keyHint ?? ""}` : "未配置"}</p><label>Preset<select onChange={(e) => { const form = e.currentTarget.form!; const preset = e.target.value; if (preset === "openai") { form.providerId.value="openai"; form.api.value="openai-responses"; form.modelId.value="gpt-5"; form.baseUrl.value=""; } else if (preset === "anthropic") { form.providerId.value="anthropic"; form.api.value="anthropic-messages"; form.modelId.value="claude-sonnet-4-5"; form.baseUrl.value=""; } else { form.providerId.value="compatible"; form.api.value="openai-completions"; } }}><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option><option value="compatible">OpenAI-compatible</option></select></label><div className="form-grid"><label>Provider ID<input name="providerId" defaultValue={model.providerId ?? "openai"} required /></label><label>API<select name="api" defaultValue={model.api ?? "openai-responses"}><option>openai-responses</option><option>openai-completions</option><option>anthropic-messages</option></select></label><label className="wide">Base URL<input name="baseUrl" type="url" defaultValue={model.baseUrl ?? ""} placeholder="https://api.example.com/v1" /></label><label>Model ID<input name="modelId" defaultValue={model.modelId ?? "gpt-5"} required /></label><label>Display Name<input name="displayName" defaultValue={model.displayName ?? ""} /></label><label className="wide">API Key<div className="password-field"><input name="apiKey" type={showKey ? "text" : "password"} autoComplete="off" placeholder={model.configured ? "留空则保留已保存密钥" : "保存于本机服务端"}/><button type="button" onClick={() => setShowKey((v) => !v)}>{showKey ? "隐藏" : "显示"}</button></div></label><label className="checkbox wide"><input name="supportsImages" type="checkbox" defaultChecked={model.supportsImages ?? true}/> 此模型支持图片输入(服务端仍会校验)</label></div>{message && <p className="form-message" role="status">{message}</p>}<div className="dialog-actions"><button type="button" className="button" onClick={async (event) => { const form = event.currentTarget.form; if (!form?.reportValidity()) return; setMessage("正在保存并验证…"); try { await configure(form); const x = await store.testModel(); setMessage(x.message); } catch (e) { setMessage(e instanceof Error ? e.message : "测试失败"); } }}>测试连接</button><button type="button" className="button" onClick={async () => { try { await store.clearCredentials(); setMessage("持久凭证已清除"); } catch (error) { setMessage(error instanceof Error ? error.message : "清除凭证失败"); } }}>清除凭证</button><button className="button button--primary">保存</button></div></form></dialog>; }
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { HarnessEvent, SkillCatalogEntry } from "@agent-studio/shared";
|
||||
export function SkillsPanel({ skills, events }: { skills: SkillCatalogEntry[]; events: HarnessEvent[] }) {
|
||||
const active = new Set(events.filter((event) => event.type === "skill.requested" || event.type === "skill.loaded").map((event) => event.payload.name));
|
||||
return <aside className="side-panel skills-panel"><div className="panel-heading"><span>SKILLS CATALOG</span><b>{skills.filter((s) => s.name !== "diagnostics").length}</b></div>{skills.length === 0 ? <div className="panel-state">没有发现 Skill</div> : <div className="skill-list">{skills.map((skill) => <article key={`${skill.source}-${skill.name}`} className={`skill-item ${active.has(skill.name) ? "skill-item--active" : ""}`}><div className="skill-item__head"><strong>{skill.name}</strong><span>{skill.diagnostics.some((d) => d.level === "error") ? "Error" : skill.diagnostics.length ? "Warning" : "Valid"}</span></div><p>{skill.description}</p><small>{skill.source} · {skill.filePath}</small>{skill.diagnostics.map((d, i) => <div className={`diagnostic diagnostic--${d.level}`} key={i}>{d.level}: {d.message}</div>)}</article>)}</div>}</aside>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, ThreadPrimitive } from "@assistant-ui/react";
|
||||
import { Download, Paperclip, Send, Square, X } from "lucide-react";
|
||||
import type { AccountProfile, HarnessEvent, ReportArtifact } from "@agent-studio/shared";
|
||||
import { getRunStage } from "./analysisProgressState.js";
|
||||
|
||||
function Message() { return <MessagePrimitive.Root className="message"><div className="message__role"><MessagePrimitive.If user>你</MessagePrimitive.If><MessagePrimitive.If assistant>Agent</MessagePrimitive.If></div><div className="message__body"><MessagePrimitive.Parts /></div></MessagePrimitive.Root>; }
|
||||
function ComposerAttachment() { return <AttachmentPrimitive.Root className="attachment-chip"><AttachmentPrimitive.unstable_Thumb /><AttachmentPrimitive.Name /><AttachmentPrimitive.Remove aria-label="移除附件"><X size={14}/></AttachmentPrimitive.Remove></AttachmentPrimitive.Root>; }
|
||||
export function ThreadView({ configured, accountProfile, artifact, error, running, events }: { configured: boolean; accountProfile: AccountProfile | undefined; artifact: ReportArtifact | undefined; error: string | undefined; running: boolean; events: HarnessEvent[] }) {
|
||||
return <section className="chat-panel" aria-label="Streaming Chat">
|
||||
<ThreadPrimitive.Root className="thread-root">
|
||||
<ThreadPrimitive.Viewport className="thread-viewport">
|
||||
<ThreadPrimitive.Empty><div className="empty"><div className="empty__mark">✦</div><span className="empty__eyebrow">DAILY CAMPAIGN INTELLIGENCE</span><h2>今天,想分析哪个账户?</h2><div className="empty__hint"><Paperclip size={15}/>先点击顶部生成日报,再上传当天报表</div></div></ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.Messages components={{ UserMessage: Message, AssistantMessage: Message }} />
|
||||
</ThreadPrimitive.Viewport>
|
||||
{running && <div className="run-activity" role="status" aria-live="polite"><div className="run-activity__orb"><span/><span/><i>DC</i></div><div><strong>{getRunStage(events)}</strong><small>任务仍在运行,请勿关闭页面</small></div></div>}
|
||||
{artifact && <div className="artifact-bar"><div><strong>Word 报告已生成</strong><small>{(artifact.byteSize / 1024).toFixed(1)} KB</small></div><a className="button button--primary" href={artifact.downloadUrl} download={artifact.name}><Download size={16}/>下载 Word</a></div>}
|
||||
<ComposerPrimitive.Root className="composer">
|
||||
{!configured && <div className="composer-notice">请先在模型设置中配置连接,随后才能发送。</div>}
|
||||
{configured && error && <div className="composer-notice" role="alert">{error}</div>}
|
||||
{accountProfile && <div className="account-reference" role="status"><div className="account-reference__head"><div><small>已引用账户配置</small><strong>{accountProfile.accountName}账户 KPI</strong></div></div><div className="account-reference__chips">{accountProfile.kpis.map((kpi) => <span key={`${kpi.metric}-${kpi.scope}`}>{kpi.metric} {kpi.target}</span>)}<span>{accountProfile.analysisDatePolicy === "yesterday" ? "分析昨天" : "最新完整日"}</span>{accountProfile.pacing.enabled && <span>日消耗 {accountProfile.pacing.targetValue} {accountProfile.pacing.currency} · {accountProfile.pacing.targetType === "cap" ? "上限" : accountProfile.pacing.targetType === "minimum" ? "最低" : accountProfile.pacing.targetType === "desired" ? "期望" : "区间"}</span>}</div><p>请继续上传 Excel / CSV 日报;文件与上述 KPI 会在发送时合并分析。</p></div>}
|
||||
<ComposerPrimitive.Attachments components={{ Attachment: ComposerAttachment }} />
|
||||
<ComposerPrimitive.Input className="composer-input" placeholder="输入任务,或拖拽 / 粘贴图片、Excel、CSV…" aria-label="消息" />
|
||||
<div className="composer-actions">
|
||||
<ComposerPrimitive.AddAttachment className="icon-button" aria-label="添加附件" title="添加图片、XLSX、CSV 或 TSV"><Paperclip size={18}/></ComposerPrimitive.AddAttachment>
|
||||
<span className="composer-help">引用账户 KPI 并上传报表后,再开始生成日报</span>
|
||||
<ThreadPrimitive.If running><ComposerPrimitive.Cancel className="button button--danger" aria-label="停止运行"><Square size={15}/>停止</ComposerPrimitive.Cancel></ThreadPrimitive.If>
|
||||
<ThreadPrimitive.If running={false}><ComposerPrimitive.Send className="button button--primary" disabled={!configured}><Send size={16}/>生成日报</ComposerPrimitive.Send></ThreadPrimitive.If>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</ThreadPrimitive.Root>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { HarnessEvent } from "@agent-studio/shared";
|
||||
|
||||
function has(events: HarnessEvent[], type: HarnessEvent["type"], predicate?: (event: HarnessEvent) => boolean) {
|
||||
return events.some((event) => event.type === type && (!predicate || predicate(event)));
|
||||
}
|
||||
|
||||
export function currentRunEvents(events: HarnessEvent[]) {
|
||||
let latest: HarnessEvent | undefined;
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
if (events[index]?.type === "run.started") {
|
||||
latest = events[index];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return latest?.runId ? events.filter((event) => event.runId === latest.runId) : [];
|
||||
}
|
||||
|
||||
export function getRunStage(events: HarnessEvent[]) {
|
||||
const runEvents = currentRunEvents(events);
|
||||
if (has(runEvents, "artifact.failed") || has(runEvents, "run.failed")) return "任务遇到问题,请查看右侧提示";
|
||||
if (has(runEvents, "artifact.ready")) return "Word 日报已生成";
|
||||
if (has(runEvents, "tool.requested", (event) => event.type === "tool.requested" && event.payload.toolName === "build_daily_brief")) return "正在套用 Skill 模板生成 Word";
|
||||
if (has(runEvents, "assistant.delta") || has(runEvents, "assistant.completed")) return "正在整理分析结论并生成 Word";
|
||||
if (has(runEvents, "tool.requested", (event) => event.type === "tool.requested" && event.payload.toolName === "analyze_uploaded_report")) return "正在解析 Excel 并计算指标";
|
||||
if (has(runEvents, "skill.requested") || has(runEvents, "skill.loaded")) return "正在加载日报分析规则";
|
||||
return "正在启动日报分析任务";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.js";
|
||||
import "./styles/app.css";
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SkillsPanel } from "../src/components/SkillsPanel.js";
|
||||
import { EventTimeline } from "../src/components/EventTimeline.js";
|
||||
describe("training panels",()=>{
|
||||
it("renders Skills diagnostics and active state",()=>{ render(<SkillsPanel skills={[{name:"ads-analysis",description:"广告分析",source:"project",filePath:".agents/skills/ads-analysis/SKILL.md",diagnostics:[]}]} events={[]}/>); expect(screen.getByText("ads-analysis")).toBeVisible(); expect(screen.getByText(/Valid/)).toBeVisible(); });
|
||||
it("renders empty and reconnecting timeline states",()=>{ render(<EventTimeline events={[]} reconnecting/>); expect(screen.getByText("重连中")).toBeVisible(); expect(screen.getByText(/等待 Harness Event/)).toBeVisible(); });
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HarnessClientStore } from "../src/assistant/HarnessClientStore.js";
|
||||
import { toolkit } from "../src/assistant/useHarnessRuntime.js";
|
||||
describe("web harness adapter",()=>{
|
||||
it("registers the analyzer and Skill-owned Word renderer",()=>{ expect(Object.keys(toolkit)).toEqual(expect.arrayContaining(["mock_ads_metrics","analyze_uploaded_report","build_daily_brief"])); expect(Object.keys(toolkit)).not.toContain("save_report_draft"); });
|
||||
it("never writes an API key to Web Storage",async()=>{ const store=new HarnessClientStore(); Object.defineProperty(store,"state",{value:{...store.getSnapshot(),sessionId:"s"},writable:true}); const fetchMock=vi.spyOn(globalThis,"fetch").mockResolvedValue(new Response(JSON.stringify({model:{source:"session",configured:true,providerId:"openai",modelId:"gpt-5",supportsImages:false,keyHint:"••••cret"}}),{status:200,headers:{"Content-Type":"application/json"}})); const key="sk-training-super-secret"; await store.configureModel({providerId:"openai",api:"openai-responses",apiKey:key,modelId:"gpt-5",supportsImages:false}); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); expect(store.getSnapshot().model).not.toHaveProperty("apiKey"); fetchMock.mockRestore(); });
|
||||
it("turns browser fetch failures into an actionable server message",async()=>{ const store=new HarnessClientStore(); const fetchMock=vi.spyOn(globalThis,"fetch").mockRejectedValue(new TypeError("Failed to fetch")); await expect(store.request("/health")).rejects.toThrow("默认端口 3001"); fetchMock.mockRestore(); });
|
||||
it("recreates an expired in-memory session and retries the model request",async()=>{
|
||||
const store=new HarnessClientStore(); Object.defineProperty(store,"state",{value:{...store.getSnapshot(),sessionId:"old"},writable:true}); Object.defineProperty(store,"connect",{value:vi.fn()});
|
||||
const json=(value:unknown,status=200)=>new Response(JSON.stringify(value),{status,headers:{"Content-Type":"application/json"}});
|
||||
const fetchMock=vi.spyOn(globalThis,"fetch")
|
||||
.mockResolvedValueOnce(json({error:{code:"SESSION_NOT_FOUND",message:"Session not found"}},404))
|
||||
.mockResolvedValueOnce(json({sessionId:"fresh"},201))
|
||||
.mockResolvedValueOnce(json({model:{source:"missing",configured:false}}))
|
||||
.mockResolvedValueOnce(json({model:{source:"session",configured:true,providerId:"openai",modelId:"gpt-5",supportsImages:false}}));
|
||||
await store.configureModel({providerId:"openai",api:"openai-responses",apiKey:"secret",modelId:"gpt-5",supportsImages:false});
|
||||
expect(store.getSnapshot().sessionId).toBe("fresh"); expect(store.getSnapshot().model.configured).toBe(true); expect(fetchMock.mock.calls[3]?.[0]).toBe("/api/sessions/fresh/model-config"); fetchMock.mockRestore();
|
||||
});
|
||||
it("creates a session before model actions instead of requesting sessions/undefined",async()=>{
|
||||
const store=new HarnessClientStore(); Object.defineProperty(store,"connect",{value:vi.fn()});
|
||||
const json=(value:unknown,status=200)=>new Response(JSON.stringify(value),{status,headers:{"Content-Type":"application/json"}});
|
||||
const fetchMock=vi.spyOn(globalThis,"fetch")
|
||||
.mockResolvedValueOnce(json({sessionId:"created"},201))
|
||||
.mockResolvedValueOnce(json({skills:[]}))
|
||||
.mockResolvedValueOnce(json({model:{source:"missing",configured:false}}))
|
||||
.mockResolvedValueOnce(json({profile:null}))
|
||||
.mockResolvedValueOnce(json({model:{source:"session",configured:true,providerId:"openai",modelId:"gpt-5",supportsImages:false}}));
|
||||
await store.configureModel({providerId:"openai",api:"openai-responses",apiKey:"secret",modelId:"gpt-5",supportsImages:false});
|
||||
expect(fetchMock.mock.calls.map(([url])=>String(url))).not.toContain(expect.stringContaining("/undefined/"));
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty("Content-Type");
|
||||
expect(fetchMock.mock.calls[4]?.[0]).toBe("/api/sessions/created/model-config"); fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "noEmit": true, "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] },
|
||||
"include": ["src", "tests", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({ plugins: [react()], server: { port: 5173, proxy: { "/api": "http://localhost:3001", "/health": "http://localhost:3001" } } });
|
||||
Reference in New Issue
Block a user