Initial Daily Campaign Agent release
This commit is contained in:
@@ -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