Initial Daily Campaign Agent release

This commit is contained in:
joliehill294-hue
2026-09-16 21:00:13 +08:00
commit 14fc94b3f0
86 changed files with 10766 additions and 0 deletions
@@ -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>; }
+5
View File
@@ -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>;
}
+32
View File
@@ -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 "正在启动日报分析任务";
}