Enhance Beryl Agent detail and report views
This commit is contained in:
@@ -5,11 +5,12 @@ import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { AdsMonitor } from "./monitor.js";
|
||||
import {
|
||||
MessageInputSchema,
|
||||
ModelConnectionInputSchema,
|
||||
} from "@agent-studio/shared";
|
||||
import { SessionRegistry, SkillCatalog } from "@agent-studio/harness";
|
||||
import { SessionRegistry, SkillCatalog, validatedPpt } from "@agent-studio/harness";
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../",
|
||||
@@ -28,16 +29,32 @@ export async function buildApp() {
|
||||
},
|
||||
});
|
||||
const registry = new SessionRegistry(root);
|
||||
const monitor = new AdsMonitor(path.join(root,"workspace/monitoring"));
|
||||
await monitor.init();
|
||||
registry.monitorSnapshot = () => ({ ...monitor.state, busy: monitor.busy });
|
||||
registry.monitoringBusy = () => monitor.busy;
|
||||
monitor.canCheck = () => !registry.hasActiveRuns();
|
||||
app.addHook("onClose",async()=>monitor.close());
|
||||
app.get("/api/monitoring",async()=>({ ...monitor.state,busy:monitor.busy }));
|
||||
app.post("/api/monitoring/check",async()=>monitor.check());
|
||||
app.put("/api/monitoring/settings",async req=>monitor.configure(req.body));
|
||||
app.post("/api/monitoring/handled",async req=>{
|
||||
const key=(req.body as {key?:unknown})?.key;
|
||||
if(typeof key!=="string") throw new Error("INVALID_ALERT_KEY");
|
||||
return monitor.handle(key);
|
||||
});
|
||||
await registry.restore();
|
||||
const skills = new SkillCatalog(root);
|
||||
await skills.reload();
|
||||
app.decorate("registry", registry);
|
||||
app.get("/health", async (req) => ({ ok: true, requestId: req.id }));
|
||||
app.get("/api/skills", async (req) => ({
|
||||
skills: skills.items,
|
||||
skills: await skills.reload(),
|
||||
requestId: req.id,
|
||||
}));
|
||||
app.post("/api/sessions", async (req, reply) => {
|
||||
const s = await registry.create();
|
||||
const resumeId = (req.body as { resumeSessionId?: unknown } | undefined)?.resumeSessionId;
|
||||
const s = typeof resumeId === "string" ? registry.get(resumeId) ?? await registry.create() : await registry.create();
|
||||
return reply.code(201).send({ sessionId: s.sessionId, requestId: req.id });
|
||||
});
|
||||
app.delete("/api/sessions/:id", async (req, reply) => {
|
||||
@@ -127,6 +144,12 @@ export async function buildApp() {
|
||||
app.get("/api/sessions/:id/reports/:kind", async (req, reply) => {
|
||||
const params = req.params as { id: string; kind: string };
|
||||
const session = registry.require(params.id);
|
||||
if (params.kind === "document") {
|
||||
const body = await readFile(session.workspace.resolve("report.md"));
|
||||
return reply.header("cache-control", "no-store")
|
||||
.header("content-disposition", 'attachment; filename="report.md"')
|
||||
.type("text/markdown; charset=utf-8").send(body);
|
||||
}
|
||||
if (params.kind !== "daily" && params.kind !== "weekly")
|
||||
throw Object.assign(new Error("REPORT_KIND_NOT_FOUND"), { statusCode: 404 });
|
||||
const file = session.workspace.resolve(`swads-${params.kind}-report.png`);
|
||||
@@ -182,6 +205,13 @@ export async function buildApp() {
|
||||
const runId = await registry.run(s, promptText, images);
|
||||
return reply.code(202).send({ runId, requestId: req.id });
|
||||
});
|
||||
app.get("/api/sessions/:id/presentations/:artifact", async (req, reply) => {
|
||||
const { id, artifact } = req.params as { id: string; artifact: string };
|
||||
const bytes = await validatedPpt(registry.require(id).workspace, artifact);
|
||||
return reply.header("cache-control", "no-store")
|
||||
.header("content-disposition", 'attachment; filename="beryl-report.pptx"')
|
||||
.type("application/vnd.openxmlformats-officedocument.presentationml.presentation").send(bytes);
|
||||
});
|
||||
app.post("/api/sessions/:id/cancel", async (req) => {
|
||||
await registry.cancel((req.params as { id: string }).id);
|
||||
return { ok: true, requestId: req.id };
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const fatigueSettingsSchema = z.object({
|
||||
fatigueCtrDrop: z.number().min(5).max(90).default(25),
|
||||
fatigueViewDrop: z.number().min(5).max(90).default(20),
|
||||
fatigueMinClicks: z.number().int().min(10).max(10000).default(30),
|
||||
});
|
||||
export type FatigueSettings = z.infer<typeof fatigueSettingsSchema> & { minImpressions: number };
|
||||
type Point = { date: string; impressions: number; clicks: number; plays: number | null; views: number | null };
|
||||
export type Creative = { id: string; campaignId: string; kind: string; variant: string; points: Point[] };
|
||||
export function decodeCreatives(raw: Record<string, unknown>, accountId: string, tenant: string, from: string, to: string) {
|
||||
const data = z.object({ columns:z.array(z.object({name:z.string()})),rows:z.array(z.array(z.unknown())),account_context:z.object({external_account_id:z.string(),tenant_id:z.string()}),query_meta:z.object({truncated:z.boolean(),attribution_resolutions:z.array(z.object({status:z.string(),freshness:z.string().nullable()})).optional()}) }).parse(raw);
|
||||
if(data.account_context.external_account_id!==accountId || data.account_context.tenant_id!==tenant) throw new Error("素材响应账户不符");
|
||||
if(data.query_meta.truncated || data.rows.length>=5000) throw new Error("素材结果被截断,未完成全量疲劳检查");
|
||||
const map=new Map<string,Creative>(); const seen=new Set<string>(); let excluded=0;
|
||||
for(const row of data.rows) {
|
||||
const o=Object.fromEntries(data.columns.map((c,i)=>[c.name,row[i]]));
|
||||
if(!["video","item"].includes(String(o.creative_identity_kind))) { excluded++; continue; }
|
||||
// Unlinked library videos are not Campaign delivery records; report them as excluded.
|
||||
if(o.external_campaign_id==="" || o.external_campaign_id===null) {excluded++;continue;}
|
||||
const identity=z.object({external_creative_id:z.string().min(1),external_campaign_id:z.string().min(1),creative_variant:z.string().nullish()}).parse(o);
|
||||
const id=identity.external_creative_id,campaignId=identity.external_campaign_id;
|
||||
const kind=String(o.creative_identity_kind),variant=identity.creative_variant??"";
|
||||
const date=z.string().regex(/^\d{4}-\d{2}-\d{2}$/).parse(o.date);
|
||||
if(date<from || date>to) throw new Error("素材日期超出查询范围");
|
||||
const key=JSON.stringify([campaignId,id,kind,variant]), daily=JSON.stringify([key,date]);
|
||||
if(seen.has(daily)) throw new Error("素材日指标重复,未进行汇总"); seen.add(daily);
|
||||
const number=(name:string)=>z.number().finite().nonnegative().parse(o[name]);
|
||||
const optional=(name:string)=>o[name]==null?null:number(name);
|
||||
const p={date,impressions:number("product_impressions"),clicks:number("product_clicks"),plays:optional("video_play_actions"),views:optional("video_watched_6s")};
|
||||
if(p.clicks>p.impressions) throw new Error("素材点击数超过曝光,口径待核实");
|
||||
const c=map.get(key)??{id,campaignId,kind,variant,points:[]}; c.points.push(p); map.set(key,c);
|
||||
}
|
||||
return {map,excluded,resolutions:data.query_meta.attribution_resolutions??[]};
|
||||
}
|
||||
function totals(points:Point[]) {
|
||||
const impressions=points.reduce((n,p)=>n+p.impressions,0),clicks=points.reduce((n,p)=>n+p.clicks,0);
|
||||
const viewing=points.length>0&&points.every(p=>p.plays!==null&&p.plays>0&&p.views!==null&&p.views<=p.plays);
|
||||
const plays=viewing?points.reduce((n,p)=>n+p.plays!,0):0;
|
||||
return {impressions,clicks,days:points.filter(p=>p.impressions>0).length,ctr:impressions>0?clicks/impressions:null,viewRate:plays>0?points.reduce((n,p)=>n+p.views!,0)/plays:null};
|
||||
}
|
||||
export function evaluateFatigue(c:Creative, split:string, s:FatigueSettings, fresh:boolean, blocked?:string) {
|
||||
const recent=totals(c.points.filter(p=>p.date>=split)),baseline=totals(c.points.filter(p=>p.date<split));
|
||||
const ctrDrop=baseline.ctr&&recent.ctr!==null?(1-recent.ctr/baseline.ctr)*100:null;
|
||||
const viewDrop=baseline.viewRate&&recent.viewRate!==null?(1-recent.viewRate/baseline.viewRate)*100:null;
|
||||
const suggestions=["先核对受众、预算、目标、价格、库存及归因变化;本规则未自动排除这些原因", "人工测试新开头或新卖点,在相近投放条件下对照;24小时后复查,不自动暂停或调整预算"];
|
||||
const out=(state:string,reason:string)=>({id:c.id,campaignId:c.campaignId,kind:c.kind,variant:c.variant,recent,baseline,ctrDrop,viewDrop,state,reason,suggestions,frequencyAvailable:false});
|
||||
if(!fresh || blocked) return out("data_issue",blocked??"素材数据同步过期或不可用,未判定疲劳");
|
||||
if(recent.days<3||baseline.days<3||recent.impressions<s.minImpressions||baseline.impressions<s.minImpressions||baseline.clicks<s.fatigueMinClicks) return out("observe","历史覆盖或样本不足:近期需3个有曝光日,基线至少3日,两个窗口均需达到曝光门槛且基线点击达标");
|
||||
if(ctrDrop===null||ctrDrop<s.fatigueCtrDrop) return out("normal","未触发点击率衰退规则,不代表素材已被全面验收");
|
||||
if(viewDrop!==null&&viewDrop>=s.fatigueViewDrop) return out("suspected_fatigue","商品点击率与6秒观看率同时衰退:疑似素材疲劳,需人工排除其他原因");
|
||||
return out("creative_decline",viewDrop===null?"商品点击率明显下降;缺少有效观看数据及准确频次,疲劳证据不足":"商品点击率明显下降,但观看衰退未达门槛,暂不判定疲劳");
|
||||
}
|
||||
export type FatigueResult=ReturnType<typeof evaluateFatigue> & {key:string;handled:boolean;period:string;account:{id:string;tenant:string;name:string;timezone:string;currency:string}};
|
||||
@@ -13,4 +13,4 @@ try {
|
||||
}
|
||||
|
||||
const app = await buildApp();
|
||||
await app.listen({ host: "0.0.0.0", port: Number(process.env.PORT) || 3001 });
|
||||
await app.listen({ host: process.env.HOST ?? "0.0.0.0", port: Number(process.env.PORT) || 3001 });
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { readSwadsMonitoring } from "@agent-studio/harness";
|
||||
import { decodeCreatives, evaluateFatigue, fatigueSettingsSchema, type FatigueResult } from "./fatigue.js";
|
||||
export const settingsSchema = z.object({ enabled: z.boolean(), minSpend: z.number().positive().max(1000000), minOrders: z.number().int().min(1).max(10000), minImpressions: z.number().int().min(100), staleHours: z.number().min(1).max(72) }).extend(fatigueSettingsSchema.shape);
|
||||
export type Settings = z.infer<typeof settingsSchema>;
|
||||
export const defaults: Settings = settingsSchema.parse({ enabled: false, minSpend: 100, minOrders: 5, minImpressions: 1000, staleHours: 12 });
|
||||
type Metrics = { spend: number; revenue: number; orders: number; impressions: number; clicks: number };
|
||||
export type Campaign = { id: string; name: string; target: number | null; active: boolean; synced: string | null; started: string | null; pending: boolean; budget: number | null; metrics?: Metrics; previous?: Metrics };
|
||||
export function evaluate(c: Campaign, s: Settings, now: number, fresh: boolean) {
|
||||
const m = c.metrics, p = c.previous;
|
||||
const roi = m && m.spend > 0 ? m.revenue / m.spend : null;
|
||||
const cpa = m && m.orders > 0 ? m.spend / m.orders : null;
|
||||
const ctr = m && m.impressions > 0 ? m.clicks / m.impressions : null;
|
||||
const out = (state: string, reason: string, suggestions: string[]) => ({ ...c, roi, cpa, ctr, state, reason, suggestions });
|
||||
if (!c.active) return out("inactive", "Campaign 未启用", ["不建议扩量;如需重启,先人工检查配置"]);
|
||||
if (!fresh || !c.synced || !Number.isFinite(Date.parse(c.synced)) || now - Date.parse(c.synced) > s.staleHours * 3600000 || c.pending) return out("data_issue", "数据/配置同步过期、不可用或有未发布修改", ["先核对平台同步及待发布配置,不调整投放"]);
|
||||
if (!c.target || c.target <= 0) return out("target_missing", "未读取到 Campaign 目标 ROI", ["请在平台核实目标;不会默认使用 2.5"]);
|
||||
if (!m) return out("data_issue", "Campaign 无指标行,不能视作零消耗", ["检查报表覆盖与同步"]);
|
||||
if (m.spend < s.minSpend || !c.started || !Number.isFinite(Date.parse(c.started)) || now - Date.parse(c.started) < 72 * 3600000) return out("observe", "消耗不足或新 Campaign,先观察", ["补足样本;24小时后复查"]);
|
||||
const previousRoi = p && p.spend >= s.minSpend ? p.revenue / p.spend : null;
|
||||
if (m.orders === 0) return out("no_orders", "达到消耗门槛但无订单", [p && p.spend >= s.minSpend && p.orders === 0 ? "连续两个完整业务日无订单:核查归因、库存后,人工评估暂停" : "核查归因、库存和商品页;暂不扩量", "24小时后复查"]);
|
||||
if (m.orders < s.minOrders) return out("observe", "订单样本不足", ["继续观察,暂不调整预算或目标"]);
|
||||
const ideas: string[] = [];
|
||||
if (ctr !== null && p && p.impressions >= s.minImpressions && m.impressions >= s.minImpressions && p.clicks > 0 && ctr < (p.clicks / p.impressions) * 0.7) ideas.push("商品 CTR 较前日下降超30%:检查素材层级,测试新开头/卖点;不能仅凭 Campaign 指标断定素材疲劳");
|
||||
if (p && p.orders >= s.minOrders && p.spend > 0 && cpa! > (p.spend / p.orders) * 1.3) ideas.push("CPA 较前日上升超30%:检查商品页、转化追踪及素材质量");
|
||||
if (roi! >= c.target * 1.2 && previousRoi !== null && previousRoi >= c.target && p!.orders >= s.minOrders) return out("potential", "连续两日达到目标,最新 ROI 高于目标20%以上", ["高潜候选:人工评估预算增加10%,确认库存和毛利后执行;48小时复查", ...ideas]);
|
||||
if (roi! < c.target * 0.8) return out("under_target", "ROI 低于本 Campaign 目标的80%(不等同亏损)", [previousRoi !== null && previousRoi < c.target * 0.8 ? "连续两日不达标:人工评估降低预算10%;严重时核查归因与毛利后评估暂停" : "暂不扩量,等待下一完整业务日确认", "复核目标 ROI 是否与毛利和历史表现匹配,不为达标而机械降低目标", ...ideas, "24小时后复查"]);
|
||||
return out(ideas.length ? "creative_review" : "normal", ideas.length ? "效率趋势异常,需进一步诊断" : "样本达门槛,未触发异常规则", ideas.length ? ideas : ["保持配置,按日观察"]);
|
||||
}
|
||||
type Account = { id: string; name: string; tenant: string; timezone: string; currency: string };
|
||||
type Result = ReturnType<typeof evaluate> & { key: string; handled: boolean; account?: Account; period?: string };
|
||||
type AccountResult = Account & { period?: string; error?: string; fatigueError?:string; fatigueCount?:number; fatigueExcluded?:number; count: number; excluded: number; checkedAt?: string };
|
||||
type State = { settings: Settings; account?: Account; accounts?: AccountResult[]; lastSuccess?: string; lastCheck?: string; period?: string; error?: string; rows: Result[]; fatigueRows?:FatigueResult[]; handled: string[]; newAlerts: number };
|
||||
const accountSchema = z.object({ external_account_id: z.string(), account_name: z.string(), tenant_id: z.string(), platform: z.string(), reporting_timezone: z.string(), reporting_utc_offset_minutes: z.number(), account_currency: z.string() });
|
||||
const campaignSchema = z.object({ external_campaign_id: z.string(), campaign_name: z.string(), external_account_id: z.string(), subject_type: z.string(), operation_status: z.string().nullable(), last_synced_at: z.string().nullable(), pending_workspace_changes: z.object({ total: z.number() }).optional(), commerce_summary: z.object({ target_roi: z.number().nullable().optional(), daily_budget: z.number().nullable().optional(), schedule_start_at: z.string().nullable().optional() }).nullable().optional() });
|
||||
export function decodeMetrics(raw: Record<string, unknown>) {
|
||||
const data = z.object({ columns: z.array(z.object({ name: z.string() })), rows: z.array(z.array(z.unknown())), query_meta: z.object({ truncated: z.boolean(), attribution_resolutions: z.array(z.object({ freshness: z.string().nullable(), status: z.string() })).optional() }) }).parse(raw);
|
||||
if (data.query_meta.truncated || data.rows.length >= 5000) throw new Error("指标结果被截断,不能完成全量检查");
|
||||
const map = new Map<string, Metrics>();
|
||||
for (const row of data.rows) {
|
||||
const obj = Object.fromEntries(data.columns.map((c,i) => [c.name,row[i]]));
|
||||
const id = z.string().parse(obj.external_campaign_id);
|
||||
if (map.has(id)) throw new Error("出现重复 Campaign 指标行");
|
||||
const number = (key: string) => z.number().finite().nonnegative().parse(obj[key]);
|
||||
map.set(id,{ spend:number("spend"),revenue:number("gross_revenue"),orders:number("orders"),impressions:number("product_impressions"),clicks:number("product_clicks") });
|
||||
}
|
||||
return { map, resolutions: data.query_meta.attribution_resolutions ?? [] };
|
||||
}
|
||||
export class AdsMonitor {
|
||||
state: State = { settings: defaults, rows: [], handled: [], newAlerts: 0 };
|
||||
busy = false;
|
||||
canCheck: () => boolean = () => true;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
constructor(private dir: string, private read = readSwadsMonitoring) {}
|
||||
async init() {
|
||||
await mkdir(this.dir,{ recursive:true, mode:0o700 });
|
||||
try { this.state = JSON.parse(await readFile(path.join(this.dir,"state.json"),"utf8")); this.state.settings = settingsSchema.parse(this.state.settings); }
|
||||
catch(e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; }
|
||||
this.timer = setInterval(() => { if (this.state.settings.enabled) void this.check().catch(()=>{this.state.error="预警保存失败,请检查磁盘";}); },3600000);
|
||||
this.timer.unref();
|
||||
}
|
||||
close() { clearInterval(this.timer); }
|
||||
private async save() { const file = path.join(this.dir,`state-${crypto.randomUUID()}.tmp`); await writeFile(file,JSON.stringify(this.state),{mode:0o600}); await rename(file,path.join(this.dir,"state.json")); }
|
||||
async configure(input: unknown) { if (this.busy) throw new Error("检查中,请稍后修改"); this.state.settings = settingsSchema.parse(input); await this.save(); return this.state; }
|
||||
async handle(key: string) { if(this.busy) throw new Error("检查中,请稍后处理"); const rows=[...this.state.rows,...this.state.fatigueRows??[]]; if(!rows.some(r=>r.key===key)) throw new Error("预警不存在"); this.state.handled = [...new Set([...this.state.handled,key])].slice(-5000); rows.forEach(r=>{r.handled=this.state.handled.includes(r.key);}); await this.save(); return this.state; }
|
||||
async check() {
|
||||
if (this.busy) return this.state;
|
||||
if (!this.canCheck()) { this.state.error="Agent 正在执行任务,为避免账户切换冲突,本次检查延后;请任务结束后重试"; await this.save(); return this.state; }
|
||||
this.busy = true;
|
||||
try {
|
||||
const who = z.object({access:z.object({org_id:z.string(),current_account:accountSchema,accounts:z.array(accountSchema)})}).parse(await this.read("swads_whoami"));
|
||||
const original = who.access.current_account;
|
||||
const visible = who.access.accounts.filter(a=>a.platform==="tiktok");
|
||||
if(new Set(visible.map(a=>`${a.tenant_id}:${a.external_account_id}`)).size!==visible.length) throw new Error("账户列表重复,检查中止");
|
||||
const rows: Result[] = [], accounts: AccountResult[] = [], fatigueRows:FatigueResult[]=[];
|
||||
let restoreError: string | undefined;
|
||||
try {
|
||||
for(const a of visible) {
|
||||
const account: Account = {id:a.external_account_id,name:a.account_name,tenant:a.tenant_id,timezone:a.reporting_timezone,currency:a.account_currency};
|
||||
try {
|
||||
const collected=await this.collect(a,who.access.org_id);
|
||||
rows.push(...collected.rows.map(r=>({...r,account,period:collected.period})));
|
||||
const result:AccountResult={...account,period:collected.period,count:collected.rows.length,excluded:collected.excluded,checkedAt:new Date().toISOString()};
|
||||
try { const fatigue=await this.collectFatigue(a,who.access.org_id,collected.rows); fatigueRows.push(...fatigue.rows); result.fatigueCount=fatigue.rows.length; result.fatigueExcluded=fatigue.excluded; }
|
||||
catch(e) { result.fatigueError=this.describeError(e); }
|
||||
accounts.push(result);
|
||||
} catch(e) { accounts.push({...account,count:0,excluded:0,error:this.describeError(e)}); }
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await this.read("campaign_list",{external_account_id:original.external_account_id,tenant_id:original.tenant_id,page:1,page_size:1});
|
||||
const end=z.object({access:z.object({current_account:accountSchema})}).parse(await this.read("swads_whoami"));
|
||||
if(end.access.current_account.external_account_id!==original.external_account_id || end.access.current_account.tenant_id!==original.tenant_id) restoreError="未能恢复原 SW Ads 当前账户,请在平台核对账户选择";
|
||||
} catch { restoreError="未能恢复原 SW Ads 当前账户,请在平台核对账户选择"; }
|
||||
}
|
||||
const old=new Set([...this.state.rows,...this.state.fatigueRows??[]].map(r=>r.key));
|
||||
const failures=accounts.filter(a=>a.error).length;
|
||||
const error=[restoreError,failures?`${failures} 个账户检查失败,请查看各账户原因`:undefined,accounts.some(a=>a.fatigueError)?"部分账户素材检查失败,不能视为正常":undefined,!visible.length?"没有可访问的 TikTok 账户":undefined].filter(Boolean).join(";")||undefined;
|
||||
this.state={...this.state,account:undefined,period:undefined,accounts,rows,fatigueRows,lastCheck:new Date().toISOString(),lastSuccess:error?this.state.lastSuccess:new Date().toISOString(),error,newAlerts:[...rows,...fatigueRows].filter(r=>!old.has(r.key)&&!["normal","observe","inactive"].includes(r.state)).length};
|
||||
} catch(e) { this.state.error = this.describeError(e); }
|
||||
finally { this.busy=false; await this.save(); }
|
||||
return this.state;
|
||||
}
|
||||
private describeError(e: unknown) { return e instanceof z.ZodError ? `SW Ads 字段不符:${e.issues.map(i=>i.path.join(".")).slice(0,5).join(",")},未执行判断` : e instanceof Error ? e.message.slice(0,200) : "检查失败"; }
|
||||
private async collectFatigue(a:z.infer<typeof accountSchema>,org:string,campaigns:Result[]) {
|
||||
if(!campaigns.length) return {rows:[],excluded:0};
|
||||
const date=(ago:number)=>new Date(Date.now()+a.reporting_utc_offset_minutes*60000-ago*86400000).toISOString().slice(0,10);
|
||||
const from=date(10),split=date(3),to=date(1),period=`${split}~${to} 对比 ${from}~${date(4)}`;
|
||||
const data:ReturnType<typeof decodeCreatives>={map:new Map(),excluded:0,resolutions:[]};
|
||||
// Query each complete day to avoid the API's 5000-row cap across a ten-day window.
|
||||
for(let ago=10;ago>=1;ago--) {
|
||||
const day=date(ago);
|
||||
const part=decodeCreatives(await this.read("metrics_semantic_query",{query:{tenant_id:a.tenant_id,org_id:org,platform:a.platform,account_id:a.external_account_id,query_kind:"creative_breakdown",metrics:["product_impressions","product_clicks","video_play_actions","video_watched_6s"],dimensions:["external_campaign_id","external_creative_id","creative_identity_kind","creative_variant","date"],date_from:day,date_to:day,reporting_timezone:a.reporting_timezone,reporting_utc_offset_minutes:a.reporting_utc_offset_minutes,limit:5000}}),a.external_account_id,a.tenant_id,day,day);
|
||||
if(!part.resolutions.length) throw new Error("素材日数据缺少同步依据,不能完成检查");
|
||||
data.resolutions.push(...part.resolutions);data.excluded+=part.excluded;
|
||||
for(const [key,c] of part.map) { const previous=data.map.get(key); if(previous) previous.points.push(...c.points); else data.map.set(key,c); }
|
||||
}
|
||||
const now=Date.now(),fresh=data.resolutions.length>0&&data.resolutions.every(r=>r.status==="available"&&r.freshness&&Number.isFinite(Date.parse(r.freshness))&&now-Date.parse(r.freshness)>=-300000&&now-Date.parse(r.freshness)<=this.state.settings.staleHours*3600000);
|
||||
const rows:FatigueResult[]=[]; let excluded=data.excluded;
|
||||
for(const c of data.map.values()) {
|
||||
const campaign=campaigns.find(x=>x.id===c.campaignId);
|
||||
if(!campaign) {excluded++;continue;}
|
||||
const blocked=!campaign.active?"Campaign 未启用,不判定素材疲劳":campaign.pending||!campaign.synced||!Number.isFinite(Date.parse(campaign.synced))||now-Date.parse(campaign.synced)>this.state.settings.staleHours*3600000?"Campaign 同步过期或有未发布修改,需先核实":undefined;
|
||||
const r=evaluateFatigue(c,split,this.state.settings,Boolean(fresh),blocked);
|
||||
const key=JSON.stringify(["fatigue",a.tenant_id,a.external_account_id,to,c.campaignId,c.id,c.kind,c.variant,r.state,this.state.settings.fatigueCtrDrop,this.state.settings.fatigueViewDrop,this.state.settings.fatigueMinClicks,this.state.settings.minImpressions]);
|
||||
rows.push({...r,key,handled:this.state.handled.includes(key),period,account:{id:a.external_account_id,tenant:a.tenant_id,name:a.account_name,timezone:a.reporting_timezone,currency:a.account_currency}});
|
||||
}
|
||||
return {rows,excluded};
|
||||
}
|
||||
private async collect(a: z.infer<typeof accountSchema>, org: string) {
|
||||
const campaigns: z.infer<typeof campaignSchema>[] = [];
|
||||
let received = 0;
|
||||
for(let page=1;page<=100;page++) {
|
||||
const list=z.object({items:z.array(z.object({subject_type:z.string()}).passthrough()),total:z.number(),account_context:z.object({external_account_id:z.string(),tenant_id:z.string()})}).parse(await this.read("campaign_list",{page,page_size:100,external_account_id:a.external_account_id,tenant_id:a.tenant_id}));
|
||||
if(list.account_context.external_account_id!==a.external_account_id||list.account_context.tenant_id!==a.tenant_id) throw new Error("Campaign 响应账户不符,结果已丢弃");
|
||||
received += list.items.length;
|
||||
campaigns.push(...list.items.filter(c=>c.subject_type==="commerce_shop").map(c=>campaignSchema.parse(c)));
|
||||
if(received>=list.total) break;
|
||||
if(!list.items.length || page===100) throw new Error("Campaign 分页不完整");
|
||||
}
|
||||
if(campaigns.some(c=>c.external_account_id!==a.external_account_id)) throw new Error("Campaign 账户校验失败");
|
||||
if(new Set(campaigns.map(c=>c.external_campaign_id)).size!==campaigns.length) throw new Error("Campaign 分页重复,结果已丢弃");
|
||||
const date = (ago: number) => new Date(Date.now()+a.reporting_utc_offset_minutes*60000-ago*86400000).toISOString().slice(0,10);
|
||||
const period=date(1), before=date(2);
|
||||
if(!campaigns.length)return {rows:[],period,excluded:received};
|
||||
const query = async (day:string) => decodeMetrics(await this.read("metrics_semantic_query",{query:{tenant_id:a.tenant_id,org_id:org,platform:a.platform,account_id:a.external_account_id,query_kind:"campaign_breakdown",metrics:["spend","gross_revenue","orders","product_impressions","product_clicks"],dimensions:["external_campaign_id"],date_from:day,date_to:day,reporting_timezone:a.reporting_timezone,reporting_utc_offset_minutes:a.reporting_utc_offset_minutes,limit:5000}}));
|
||||
const current=await query(period), previous=await query(before);
|
||||
const end=z.object({access:z.object({current_account:accountSchema})}).parse(await this.read("swads_whoami"));
|
||||
if(end.access.current_account.external_account_id!==a.external_account_id || end.access.current_account.tenant_id!==a.tenant_id) throw new Error("检查期间账户改变,结果已丢弃");
|
||||
const now=Date.now();
|
||||
const fresh=[current,previous].every(data=>data.resolutions.length>0 && data.resolutions.every(r=>r.status==="available"&&r.freshness&&Number.isFinite(Date.parse(r.freshness))&&now-Date.parse(r.freshness)>=-300000&&now-Date.parse(r.freshness)<=this.state.settings.staleHours*3600000));
|
||||
const rows=campaigns.filter(c=>c.subject_type==="commerce_shop").map(c=>{
|
||||
const summary=c.commerce_summary;
|
||||
const r=evaluate({id:c.external_campaign_id,name:c.campaign_name,target:summary?.target_roi??null,budget:summary?.daily_budget??null,active:c.operation_status==="ENABLE",synced:c.last_synced_at,started:summary?.schedule_start_at??null,pending:(c.pending_workspace_changes?.total??0)>0,metrics:current.map.get(c.external_campaign_id),previous:previous.map.get(c.external_campaign_id)},this.state.settings,now,Boolean(fresh));
|
||||
const key=[a.tenant_id,a.external_account_id,period,r.id,r.state,r.target].join(":");
|
||||
return {...r,key,handled:this.state.handled.includes(key)};
|
||||
});
|
||||
return {rows,period,excluded:received-campaigns.length};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect,it } from "vitest";
|
||||
import { decodeCreatives,evaluateFatigue,type Creative } from "../src/fatigue.js";
|
||||
import {defaults,settingsSchema} from "../src/monitor.js";
|
||||
const creative:Creative={id:"video",campaignId:"c",kind:"item",variant:"ADS_AND_ORGANIC",points:Array.from({length:10},(_,i)=>({date:`2026-09-${String(i+1).padStart(2,"0")}`,impressions:1000,clicks:i<7?100:50,plays:1000,views:i<7?500:200}))};
|
||||
it("requires corroborating viewing decline, not CTR alone",()=>{
|
||||
const r=evaluateFatigue(creative,"2026-09-08",defaults,true);
|
||||
expect(r.state).toBe("suspected_fatigue");expect(r.ctrDrop).toBe(50);expect(r.baseline.ctr).toBe(.1);
|
||||
expect(evaluateFatigue({...creative,points:creative.points.map(p=>({...p,plays:0,views:0}))},"2026-09-08",defaults,true).state).toBe("creative_decline");
|
||||
});
|
||||
it("fails closed on stale, pending, sparse and missing baseline",()=>{
|
||||
expect(evaluateFatigue(creative,"2026-09-08",defaults,false).state).toBe("data_issue");
|
||||
expect(evaluateFatigue(creative,"2026-09-08",defaults,true,"配置修改中").state).toBe("data_issue");
|
||||
expect(evaluateFatigue({...creative,points:creative.points.slice(7)},"2026-09-08",defaults,true).state).toBe("observe");
|
||||
expect(evaluateFatigue({...creative,points:creative.points.map(p=>({...p,impressions:100,clicks:1}))},"2026-09-08",defaults,true).state).toBe("observe");
|
||||
});
|
||||
it("uses weighted rates and allows zero recent clicks to trigger decline",()=>{
|
||||
const r=evaluateFatigue({...creative,points:creative.points.map((p,i)=>({...p,impressions:i===0?10000:1000,clicks:i<7?100:0}))},"2026-09-08",defaults,true);
|
||||
expect(r.baseline.ctr).toBe(700/16000);expect(r.ctrDrop).toBe(100);
|
||||
});
|
||||
it("migrates old settings and rejects invalid thresholds",()=>{
|
||||
expect(settingsSchema.parse({enabled:false,minSpend:100,minOrders:5,minImpressions:1000,staleHours:12}).fatigueCtrDrop).toBe(25);
|
||||
expect(()=>settingsSchema.parse({...defaults,fatigueCtrDrop:101})).toThrow();
|
||||
});
|
||||
it("validates scope, duplicates, dates and truncation; excludes product cards",()=>{
|
||||
const raw={columns:["external_campaign_id","external_creative_id","creative_identity_kind","creative_variant","date","product_impressions","product_clicks","video_play_actions","video_watched_6s"].map(name=>({name})),rows:[["c","v","item","ADS","2026-09-01",100,5,0,0],["c","p","product_card","ADS","2026-09-01",100,5,0,0]],account_context:{external_account_id:"a",tenant_id:"t"},query_meta:{truncated:false}};
|
||||
const decode=(r:typeof raw)=>decodeCreatives(r,"a","t","2026-09-01","2026-09-10");
|
||||
expect(decode(raw).map.size).toBe(1);expect(decode(raw).excluded).toBe(1);
|
||||
expect(()=>decode({...raw,rows:[raw.rows[0]!,raw.rows[0]!]})).toThrow("重复");
|
||||
expect(()=>decode({...raw,query_meta:{truncated:true}})).toThrow("截断");
|
||||
expect(()=>decode({...raw,account_context:{external_account_id:"b",tenant_id:"t"}})).toThrow("账户");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { it,expect } from "vitest";
|
||||
import { evaluate,defaults,decodeMetrics,AdsMonitor,type Campaign } from "../src/monitor.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
const now=Date.parse("2026-09-09T08:00:00Z");
|
||||
const base:Campaign={id:"1",name:"test",target:2,active:true,synced:"2026-09-09T07:00:00Z",started:"2026-09-01T00:00:00Z",pending:false,budget:100,metrics:{spend:100,revenue:300,orders:10,impressions:2000,clicks:100},previous:{spend:100,revenue:250,orders:10,impressions:2000,clicks:100}};
|
||||
it("uses each campaign target, never global 2.5",()=>{expect(evaluate(base,defaults,now,true).state).toBe("potential");expect(evaluate({...base,target:5},defaults,now,true).state).toBe("under_target");expect(evaluate({...base,target:null},defaults,now,true).state).toBe("target_missing");});
|
||||
it("does not confuse missing, stale or small data with loss",()=>{expect(evaluate({...base,metrics:undefined},defaults,now,true).state).toBe("data_issue");expect(evaluate(base,defaults,now,false).state).toBe("data_issue");expect(evaluate({...base,metrics:{...base.metrics!,spend:10}},defaults,now,true).state).toBe("observe");expect(evaluate({...base,pending:true},defaults,now,true).state).toBe("data_issue");});
|
||||
it("calculates ratios from totals and handles zero orders",()=>{const r=evaluate(base,defaults,now,true);expect(r.roi).toBe(3);expect(r.cpa).toBe(10);expect(r.ctr).toBe(.05);expect(evaluate({...base,metrics:{...base.metrics!,orders:0,revenue:0}},defaults,now,true).state).toBe("no_orders");});
|
||||
it("blocks truncated metrics",()=>{expect(()=>decodeMetrics({columns:[],rows:[],query_meta:{truncated:true}})).toThrow("截断");});
|
||||
it("isolates all TikTok accounts, restores selection, persists and deduplicates",async()=>{
|
||||
const dir=await mkdtemp(path.join(tmpdir(),"ads-monitor-test-"));
|
||||
let id="account-1";
|
||||
let fail=false;
|
||||
const account=(external_account_id:string)=>({external_account_id,account_name:external_account_id,tenant_id:"tenant",platform:"tiktok",reporting_timezone:"Asia/Shanghai",reporting_utc_offset_minutes:480,account_currency:external_account_id==="account-1"?"CNY":"USD"});
|
||||
const read=async(name:string,args:Record<string,unknown>={})=>{
|
||||
if(name==="swads_whoami")return {access:{org_id:"org",current_account:account(id),accounts:[account("account-1"),account("account-2"),{...account("other"),platform:"qianchuan"}]}};
|
||||
if(name==="campaign_list"){
|
||||
id=String(args.external_account_id);
|
||||
if(fail&&id==="account-2")throw new Error("账户权限不足");
|
||||
return {total:2,account_context:{external_account_id:id,tenant_id:"tenant"},items:[{subject_type:"minis",external_campaign_id:null,external_account_id:null},{subject_type:"commerce_shop",external_campaign_id:"1",external_account_id:id,campaign_name:"test",operation_status:"ENABLE",last_synced_at:new Date().toISOString(),commerce_summary:{target_roi:id==="account-1"?2:5,schedule_start_at:"2020-01-01"}}]};
|
||||
}
|
||||
const query=args.query as {query_kind?:string;date_from?:string}|undefined;
|
||||
if(query?.query_kind==="creative_breakdown") {
|
||||
const recent=query.date_from!>=new Date(Date.now()+480*60000-3*86400000).toISOString().slice(0,10);
|
||||
return {columns:["external_campaign_id","external_creative_id","creative_identity_kind","creative_variant","date","product_impressions","product_clicks","video_play_actions","video_watched_6s"].map(name=>({name})),rows:[["1","v","item","ADS_AND_ORGANIC",query.date_from,1000,recent?50:100,0,0]],account_context:{external_account_id:id,tenant_id:"tenant"},query_meta:{truncated:false,attribution_resolutions:[{status:"available",freshness:new Date().toISOString()}]}};
|
||||
}
|
||||
return {columns:["external_campaign_id","spend","gross_revenue","orders","product_impressions","product_clicks"].map(name=>({name})),rows:[["1",100,300,10,2000,100]],query_meta:{truncated:false,attribution_resolutions:[{status:"available",freshness:new Date().toISOString()}]}};
|
||||
};
|
||||
const m=new AdsMonitor(dir,read);
|
||||
const restored=new AdsMonitor(dir,read);
|
||||
try{
|
||||
await m.init();expect((await m.check()).newAlerts).toBe(4);expect(m.state.rows).toHaveLength(2);expect(id).toBe("account-1");
|
||||
expect(m.state.fatigueRows).toHaveLength(2);expect(m.state.fatigueRows?.[0]?.state).toBe("creative_decline");
|
||||
expect(m.state.fatigueRows?.[0]?.baseline.days).toBe(7);expect(m.state.fatigueRows?.[0]?.recent.days).toBe(3);
|
||||
await m.handle(m.state.fatigueRows![0]!.key);
|
||||
expect(m.state.rows.map(r=>r.state)).toEqual(["potential","under_target"]);expect(new Set(m.state.rows.map(r=>r.key)).size).toBe(2);
|
||||
await m.handle(m.state.rows[0]!.key);expect((await m.check()).newAlerts).toBe(0);
|
||||
await restored.init();expect(restored.state.rows[0]!.handled).toBe(true);expect(restored.state.fatigueRows?.[0]?.handled).toBe(true);
|
||||
fail=true;expect((await restored.check()).error).toContain("1 个账户检查失败");expect(restored.state.rows).toHaveLength(1);expect(restored.state.accounts?.[1]?.error).toContain("权限");expect(id).toBe("account-1");
|
||||
}finally{m.close();restored.close();await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from "react";
|
||||
import { BarChart3, BookOpen, ChevronRight, FileChartColumn, FolderOpen, LayoutDashboard, Lightbulb, MessageSquareText, PackageSearch, Plus, Settings2, Sparkles } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type RefObject } from "react";
|
||||
import { BarChart3, BookOpen, ChevronDown, ChevronRight, FileChartColumn, FolderOpen, History, LayoutDashboard, Lightbulb, MessageSquareText, PackageSearch, Plus, Settings2, Sparkles } from "lucide-react";
|
||||
import { Monitoring } from "./Monitoring.js";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
ComposerPrimitive,
|
||||
ThreadPrimitive,
|
||||
unstable_useComposerInput,
|
||||
useExternalStoreRuntime,
|
||||
type AppendMessage,
|
||||
type ThreadMessageLike,
|
||||
@@ -24,6 +25,21 @@ type Msg = {
|
||||
status?: "running" | "complete" | "incomplete";
|
||||
};
|
||||
type PendingImage = ImageAttachment & { previewUrl: string };
|
||||
const quickTasks = [
|
||||
["日报", "汇总昨天表现与关键异常", "生成指定店铺的昨日投放日报", FileChartColumn],
|
||||
["周报", "看清趋势、漏斗与行动建议", "生成指定店铺的上周投放周报", BarChart3],
|
||||
["分析投放", "定位商品与视频转化问题", "分析指定店铺最近一段时间的投放效果", PackageSearch],
|
||||
["策划新素材", "从数据洞察生成创意方向", "根据我的产品和投放数据策划下一批广告素材", Lightbulb],
|
||||
] as const;
|
||||
|
||||
function QuickTasks({disabled,inputRef}:{disabled:boolean;inputRef:RefObject<HTMLTextAreaElement|null>}) {
|
||||
const composer = unstable_useComposerInput();
|
||||
return <div className="quick-grid">
|
||||
{quickTasks.map(([title,copy,prompt,Icon],index)=><button type="button" className="quick-card" key={title} onClick={()=>{composer.setText(prompt);requestAnimationFrame(()=>inputRef.current?.focus());}} disabled={disabled}>
|
||||
<span className="quick-number">0{index+1}</span><span className="quick-icon"><Icon size={19}/></span><strong>{title}</strong><small>{copy}</small><ChevronRight size={17}/>
|
||||
</button>)}
|
||||
</div>;
|
||||
}
|
||||
const demo =
|
||||
"请使用 demo-report Skill,读取 demo-account 最近 7 天演示数据,分析 Campaign,生成含 3 条建议的报告草稿。保存后读取报告并核对指标,明确验收是否通过。";
|
||||
import { modelStorageKey, readSavedModel, saveModel } from "./model-storage.js";
|
||||
@@ -58,6 +74,8 @@ async function fetchJsonWithRetry<T>(
|
||||
|
||||
export function App() {
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [activePanel,setActivePanel]=useState<"history"|"daily"|"weekly"|"creative"|"prelaunch"|"library"|null>(null);
|
||||
const skillDrawerRef=useRef<HTMLDetailsElement|null>(null);
|
||||
const [session, setSession] = useState("");
|
||||
useEffect(() => { if (session) localStorage.setItem("beryl-session-id", session); }, [session]);
|
||||
const [skills, setSkills] = useState<SkillCatalogItem[]>([]);
|
||||
@@ -71,6 +89,7 @@ export function App() {
|
||||
const [attachmentError, setAttachmentError] = useState("");
|
||||
const es = useRef<EventSource | undefined>(undefined);
|
||||
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const create = useCallback(async (resume = false) => {
|
||||
es.current?.close();
|
||||
const d = await fetchJsonWithRetry<{ sessionId: string }>(
|
||||
@@ -110,9 +129,17 @@ export function App() {
|
||||
useEffect(() => {
|
||||
void fetchJsonWithRetry<{ skills: SkillCatalogItem[] }>("/api/skills")
|
||||
.then((d) => setSkills(d.skills));
|
||||
void create(true);
|
||||
void create();
|
||||
return () => es.current?.close();
|
||||
}, [create]);
|
||||
useEffect(()=>{
|
||||
const closeDrawer=(event:MouseEvent)=>{
|
||||
const drawer=skillDrawerRef.current;
|
||||
if(drawer?.open&&!drawer.contains(event.target as Node))drawer.open=false;
|
||||
};
|
||||
document.addEventListener("mousedown",closeDrawer);
|
||||
return()=>document.removeEventListener("mousedown",closeDrawer);
|
||||
},[]);
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const source = new EventSource(`/api/sessions/${session}/events`);
|
||||
@@ -350,22 +377,26 @@ export function App() {
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
{monitorOpen&&<Monitoring onClose={()=>setMonitorOpen(false)}/>}
|
||||
{monitorOpen&&<Monitoring onClose={()=>setMonitorOpen(false)}/>}
|
||||
{activePanel&&<FeaturePanel kind={activePanel} session={session} messages={msgs} onClose={()=>setActivePanel(null)}/>}
|
||||
<div className="app">
|
||||
<main>
|
||||
<aside className="nav-shell">
|
||||
<div className="brand-mark"><span>B</span><div><strong>Beryl Agent</strong><small>你的跨境投放搭档</small></div></div>
|
||||
<nav aria-label="主导航">
|
||||
<button className="nav-item active"><LayoutDashboard size={18}/>工作台</button>
|
||||
<button className="nav-item"><FileChartColumn size={18}/>数据报告</button>
|
||||
<button className="nav-item"><Lightbulb size={18}/>素材创意</button>
|
||||
<button className="nav-item"><BookOpen size={18}/>投前方案</button>
|
||||
<button className="nav-item"><FolderOpen size={18}/>素材库</button>
|
||||
<button className="nav-item active" onClick={()=>setActivePanel(null)}><LayoutDashboard size={18}/>工作台</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("history")}><History size={18}/>历史任务</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("daily")}><FileChartColumn size={18}/>日报</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("weekly")}><BarChart3 size={18}/>周报</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("creative")}><Lightbulb size={18}/>素材规划</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("prelaunch")}><BookOpen size={18}/>投前方案</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("library")}><FolderOpen size={18}/>素材库</button>
|
||||
</nav>
|
||||
<div className="nav-spacer" />
|
||||
<details className="skill-drawer">
|
||||
<details className="skill-drawer" ref={skillDrawerRef}>
|
||||
<summary><Sparkles size={17}/>技能目录 <span>{skills.length}</span></summary>
|
||||
<div className="skill-list">
|
||||
<div className="skill-list-head"><strong>全部技能</strong><button type="button" aria-label="关闭技能目录" onClick={()=>{if(skillDrawerRef.current)skillDrawerRef.current.open=false;}}>×</button></div>
|
||||
{skills.map((s) => <article key={s.filePath}><strong>{s.name}</strong><small>{s.diagnostics.length ? "需要检查" : "可使用"}</small></article>)}
|
||||
</div>
|
||||
</details>
|
||||
@@ -374,8 +405,8 @@ export function App() {
|
||||
</aside>
|
||||
<section className="chat">
|
||||
<div className="workspace-header">
|
||||
<div><small>运营工作台</small><h1>今天想先做什么?</h1></div>
|
||||
<div className="header-actions"><button onClick={()=>setMonitorOpen(true)}><BarChart3 size={16}/>SW Ads 预警</button><button title="新建会话" onClick={() => void create()}><Plus size={17}/>新任务</button></div>
|
||||
<div><small>BERYL INTELLIGENCE · 运营工作台</small><h1>今天想先做什么?</h1><p>Beryl 已准备好读取广告数据、分析表现并生成报告。</p></div>
|
||||
<div className="header-actions"><button className="store-switcher">Lemonz Home <ChevronDown size={14}/></button><span className="sync-state"><i/>数据已连接</span><button onClick={()=>setMonitorOpen(true)}><BarChart3 size={16}/>SW Ads 预警</button><button title="新建会话" onClick={() => void create()}><Plus size={17}/>新任务</button></div>
|
||||
</div>
|
||||
<ThreadPrimitive.Root className="thread-root">
|
||||
<ThreadPrimitive.Viewport className="viewport">
|
||||
@@ -405,23 +436,13 @@ export function App() {
|
||||
))
|
||||
) : (
|
||||
<div className="empty dashboard-empty">
|
||||
<div className="quick-grid">
|
||||
{([
|
||||
["日报", "汇总昨天表现与关键异常", "生成 Lemonz Home 昨日投放日报", FileChartColumn],
|
||||
["周报", "看清趋势、漏斗与行动建议", "生成 Lemonz Home 上周投放周报", BarChart3],
|
||||
["分析投放", "定位商品与视频转化问题", "分析最近三周厨具投放效果", PackageSearch],
|
||||
["策划新素材", "从数据洞察生成创意方向", "为厨具规划下一批广告素材", Lightbulb],
|
||||
] as const).map(([title, copy, prompt, Icon]) => (
|
||||
<button className="quick-card" key={String(title)} onClick={() => void onNew({ role: "user", content: [{ type: "text", text: String(prompt) }] } as unknown as AppendMessage)} disabled={!configured || running}>
|
||||
<span className="quick-icon"><Icon size={20}/></span><strong>{String(title)}</strong><small>{String(copy)}</small><ChevronRight size={17}/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<QuickTasks disabled={!configured || running} inputRef={composerInputRef}/>
|
||||
<div className="starter-note"><MessageSquareText size={17}/><span>也可以直接在下方输入你想完成的任务</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ComposerPrimitive.Root className="composer">
|
||||
<div className="composer-heading"><span>新任务</span><small>输入自然语言即可开始</small></div>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="attachment-tray" aria-label="已粘贴图片">
|
||||
{pendingImages.map((image) => (
|
||||
@@ -444,6 +465,7 @@ export function App() {
|
||||
</p>
|
||||
)}
|
||||
<ComposerPrimitive.Input
|
||||
ref={composerInputRef}
|
||||
aria-label="消息"
|
||||
submitMode="enter"
|
||||
placeholder={configured ? "输入任务,例如:生成 Lemonz Home 上周周报" : "请先连接模型,再开始任务"}
|
||||
@@ -456,6 +478,7 @@ export function App() {
|
||||
onPaste={(event) => void onPasteImage(event)}
|
||||
/>
|
||||
<div className="composer-actions">
|
||||
<span className="composer-tip">Enter 发送 · Shift + Enter 换行</span>
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<button type="button" disabled={!running}>停止</button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
@@ -523,6 +546,30 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const panelTitles={history:"历史任务",daily:"日报",weekly:"周报",creative:"素材规划",prelaunch:"投前方案",library:"素材库"} as const;
|
||||
function FeaturePanel({kind,session,messages,onClose}:{kind:keyof typeof panelTitles;session:string;messages:Msg[];onClose:()=>void}){
|
||||
const [reportReady,setReportReady]=useState<boolean|null>(null);
|
||||
useEffect(()=>{
|
||||
const close=(event:KeyboardEvent)=>{if(event.key==="Escape")onClose();};
|
||||
window.addEventListener("keydown",close);
|
||||
return()=>window.removeEventListener("keydown",close);
|
||||
},[onClose]);
|
||||
useEffect(()=>{
|
||||
if(kind!=="daily"&&kind!=="weekly")return;
|
||||
let live=true;
|
||||
void fetch(`/api/sessions/${session}/reports/${kind}`).then(response=>{if(live)setReportReady(response.ok);}).catch(()=>{if(live)setReportReady(false);});
|
||||
return()=>{live=false;};
|
||||
},[kind,session]);
|
||||
return <div className="feature-overlay" onMouseDown={event=>{if(event.target===event.currentTarget)onClose();}}>
|
||||
<section className="feature-panel" role="dialog" aria-modal="true" aria-label={panelTitles[kind]} onMouseDown={event=>event.stopPropagation()}>
|
||||
<div className="feature-panel-head"><button type="button" className="feature-back" onClick={onClose}>← 返回工作台</button><div><small>BERYL WORKSPACE</small><h1>{panelTitles[kind]}</h1></div><button type="button" className="dialog-close" onClick={onClose} aria-label={`关闭${panelTitles[kind]}`}>×</button></div>
|
||||
{kind==="history"&&<div className="history-list">{messages.length?messages.map(message=><article key={message.id}><small>{message.role==="user"?"你的任务":"Agent 结果"}</small><p>{message.text||"处理中…"}</p></article>):<div className="panel-empty"><History size={28}/><h2>暂无历史任务</h2><p>完成任务后,当前会话记录会显示在这里。</p></div>}</div>}
|
||||
{(kind==="daily"||kind==="weekly")&&<div className="report-panel">{reportReady===null?<div className="panel-empty"><p>正在读取报告…</p></div>:reportReady?<><img src={`/api/sessions/${session}/reports/${kind}`} alt={`${panelTitles[kind]}图片`}/><a className="report-open" href={`/api/sessions/${session}/reports/${kind}`} target="_blank" rel="noreferrer">打开大图</a></>:<div className="panel-empty"><FileChartColumn size={28}/><h2>暂无{panelTitles[kind]}</h2><p>返回工作台生成{panelTitles[kind]}后,会自动出现在这里。</p></div>}</div>}
|
||||
{(kind==="creative"||kind==="prelaunch"||kind==="library")&&<div className="panel-empty"><Sparkles size={28}/><h2>{panelTitles[kind]}</h2><p>该功能已打开。返回工作台输入任务,Agent 会调用对应技能处理。</p></div>}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
swads_whoami: "连接 SW Ads",
|
||||
metrics_catalog: "读取指标目录",
|
||||
@@ -752,6 +799,13 @@ function Settings({
|
||||
const [input, setInput] = useState<ModelConnectionInput>(
|
||||
() => readSavedModel() ?? defaultModelInput,
|
||||
);
|
||||
useEffect(() => {
|
||||
const close = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", close);
|
||||
return () => window.removeEventListener("keydown", close);
|
||||
}, [onClose]);
|
||||
const save = async () => {
|
||||
const submit = (sessionId: string) =>
|
||||
fetch(`/api/sessions/${sessionId}/model-config`, {
|
||||
@@ -791,8 +845,12 @@ function Settings({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="模型连接"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div onMouseDown={(event) => event.stopPropagation()}>
|
||||
<button className="dialog-close" onClick={onClose} aria-label="关闭模型设置" title="关闭">×</button>
|
||||
<h2>模型连接</h2>
|
||||
<label>
|
||||
Provider
|
||||
|
||||
@@ -18,7 +18,8 @@ export function Monitoring({onClose}:{onClose:()=>void}) {
|
||||
const num=(n:number|null|undefined)=>n==null?"—":n.toFixed(2);
|
||||
const fatigueRows=state?.fatigueRows?.filter(r=>(!accountFilter||`${r.account.tenant}:${r.account.id}`===accountFilter)&&(!fatigueState||r.state===fatigueState))??[];
|
||||
return <div className="monitor-overlay" onMouseDown={(event)=>{if(event.target===event.currentTarget)onClose();}}>
|
||||
<section className="monitor-dialog" role="dialog" aria-modal="true" aria-label="SW Ads 预警中心">
|
||||
<section className="monitor-dialog" role="dialog" aria-modal="true" aria-label="SW Ads 预警中心" onMouseDown={event=>event.stopPropagation()}>
|
||||
<button className="dialog-close" onClick={onClose} aria-label="关闭 SW Ads 预警中心" title="关闭">×</button>
|
||||
<button className="monitor-back" onClick={onClose} aria-label="返回工作台">← 返回工作台</button><h1>SW Ads · 投放与素材疲劳预警</h1>
|
||||
<p>只读监控 · 所有建议需人工确认 · ROI = 平台归因成交金额 ÷ 广告消耗,不代表净利润</p>
|
||||
<p>范围:所有可访问的 TikTok 账户({state?.accounts?.length??0} 个)。每次检查刷新账户列表;各账户按自己的时区、币种和 Campaign 目标判断,不合并不同币种。</p>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ModelConnectionInput } from "@agent-studio/shared";
|
||||
export const modelStorageKey = "agent-studio:model-config:v1";
|
||||
export function saveModel(input: ModelConnectionInput) {
|
||||
const safe = { ...input };
|
||||
delete safe.apiKey;
|
||||
localStorage.setItem(modelStorageKey, JSON.stringify(safe));
|
||||
}
|
||||
export function readSavedModel(): ModelConnectionInput | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(modelStorageKey);
|
||||
if (!value) return undefined;
|
||||
const input = JSON.parse(value) as ModelConnectionInput;
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("INVALID_CONFIG");
|
||||
delete input.apiKey;
|
||||
saveModel(input); // Migrate older configurations without retaining credentials.
|
||||
return input;
|
||||
} catch {
|
||||
localStorage.removeItem(modelStorageKey);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,20 @@ main{height:100vh;grid-template-columns:260px minmax(560px,1fr) 350px}
|
||||
@media(max-width:680px){main{grid-template-columns:1fr}.nav-shell{display:none}.workspace-header{height:auto;padding:16px 18px}.workspace-header h1{font-size:21px}.header-actions button:first-child{display:none}.messages{padding:18px}.composer{margin:10px 18px 18px}.quick-grid{grid-template-columns:1fr}.dashboard-empty{margin-top:1vh}}
|
||||
.thread-root{min-height:0;flex:1}.thread-root>.viewport{height:100%}
|
||||
.monitor-overlay{position:fixed;inset:0;z-index:1000;padding:20px;background:#17314255;backdrop-filter:blur(4px)}.monitor-dialog{position:relative;width:100%;height:100%;overflow:auto;padding:28px;background:#fff;color:#172b35;border:1px solid #ccd6da;border-radius:14px;box-shadow:0 18px 70px #10263345}.monitor-back{position:sticky;top:0;z-index:4;float:right;display:flex;align-items:center;gap:6px;margin:0 0 12px 18px;background:#0d9488;color:#fff;border-color:#0d9488;font-weight:700;box-shadow:0 6px 18px #0d948833}.monitor-back:hover{background:#0b7f76}.monitor-dialog>h1{padding-right:170px}@media(max-width:680px){.monitor-overlay{padding:0}.monitor-dialog{border-radius:0;padding:18px}.monitor-back{float:none;width:100%;justify-content:center;margin:0 0 18px}.monitor-dialog>h1{padding-right:0;font-size:22px}}
|
||||
|
||||
/* Premium workspace theme — Stitch direction */
|
||||
:root{--nav:#0b1724;--nav-hover:#142536;--teal:#0b8f83;--ink:#0e2132;--canvas:#f7f8f6;--border:#dfe5e3}
|
||||
.app{background:var(--canvas)}
|
||||
main{background:var(--canvas)}
|
||||
.nav-shell{position:relative;background:linear-gradient(180deg,#0b1724 0%,#0d1d2b 100%);padding:24px 16px 20px}.nav-shell::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.16;background-image:linear-gradient(#ffffff08 1px,transparent 1px),linear-gradient(90deg,#ffffff08 1px,transparent 1px);background-size:28px 28px}.nav-shell>*{position:relative;z-index:1}.brand-mark{padding-bottom:30px}.brand-mark>span{border-radius:12px;background:#0b8f83;box-shadow:0 10px 28px #0005}.brand-mark strong{letter-spacing:-.02em}.nav-item{border-radius:12px;transition:background .18s ease,color .18s ease,transform .18s ease}.nav-item:hover{transform:translateX(2px)}.nav-item.active{background:#edf9f6;color:#087b70;box-shadow:0 8px 24px #020b1238}.connection-card{border:1px solid #23394a;border-radius:12px;background:#0e2131;padding:13px 12px}
|
||||
.workspace-header{height:108px;padding:20px 34px;background:#fbfcf9f2;border-color:#e1e6e3}.workspace-header small{font-family:"JetBrains Mono",ui-monospace,monospace;color:#718078;font-size:10px;letter-spacing:.13em}.workspace-header h1{font-size:27px;font-weight:650;letter-spacing:-.035em}.workspace-header p{margin:7px 0 0;color:#74817b;font-size:13px}.header-actions{align-items:center;flex-wrap:wrap;justify-content:flex-end}.header-actions button{height:40px;border-radius:10px}.header-actions button:last-child{background:#0b8f83;border-color:#0b8f83;box-shadow:0 7px 18px #0b8f8326}.header-actions .store-switcher{gap:10px;background:#fffefc;color:#24372f;font-weight:650}.sync-state{display:flex;align-items:center;gap:7px;padding:0 4px;color:#63736b;font-size:12px}.sync-state i{width:7px;height:7px;border-radius:50%;background:#19a974;box-shadow:0 0 0 4px #19a97416}
|
||||
.chat{position:relative;background-color:#f7f8f6;background-image:radial-gradient(#8c9c9417 .7px,transparent .7px);background-size:18px 18px}.chat::after{content:"";position:absolute;inset:108px 0 auto;height:180px;pointer-events:none;background:linear-gradient(180deg,#eef5f140,transparent)}.thread-root,.workspace-header{position:relative;z-index:1}
|
||||
.dashboard-empty{max-width:920px;margin-top:5vh}.quick-grid{gap:16px}.quick-card{min-height:146px;grid-template-columns:46px 1fr 20px;grid-template-rows:24px auto auto;column-gap:14px;padding:18px 19px;border-radius:12px;border-color:#dfe5e3;background:#fdfefc;box-shadow:0 1px 2px #11271e08,0 10px 30px #16382b08;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease}.quick-card:hover{transform:translateY(-3px);border-color:#8fc8bd;box-shadow:0 2px 3px #11271e0a,0 18px 40px #16382b12}.quick-number{grid-column:1/4;align-self:start;font-family:"JetBrains Mono",ui-monospace,monospace;color:#9aa8a2;font-size:10px;letter-spacing:.12em}.quick-card .quick-icon{grid-row:2/4;grid-column:1;width:42px;height:42px;border-radius:12px;background:#e7f5f1;color:#087f74}.quick-card strong{grid-column:2;grid-row:2;font-size:17px;letter-spacing:-.01em}.quick-card small{grid-column:2;grid-row:3}.quick-card>svg{grid-column:3;grid-row:2/4;transition:transform .2s ease}.quick-card:hover>svg{transform:translateX(3px);color:#0b8f83}.starter-note{margin-top:24px;color:#7c8983}
|
||||
.composer{border-radius:12px;background:#fffefc;border-color:#d9e1dd;box-shadow:0 2px 3px #11271e08,0 20px 50px #183e2d10;padding:14px 15px}.composer:focus-within{border-color:#5db3a7;box-shadow:0 0 0 3px #0b8f8310,0 22px 55px #183e2d14}.composer-heading{display:flex;align-items:center;gap:9px;padding-bottom:8px;border-bottom:1px solid #edf0ed}.composer-heading span{color:#263a32;font-size:12px;font-weight:700}.composer-heading small{color:#8b9791;font-size:11px}.composer-actions{align-items:center}.composer-tip{margin-right:auto;color:#8a9690;font-size:11px}.composer-actions button{border-radius:9px}.composer-actions button:last-child{background:#0b8f83;border-color:#0b8f83;min-width:72px}
|
||||
.message.assistant{border-radius:12px;border-color:#e0e6e3;background:#fffefc;box-shadow:0 10px 34px #17382b0a}.message b{font-family:"JetBrains Mono",ui-monospace,monospace;letter-spacing:.13em}
|
||||
.loop-panel{padding:24px 20px;background:#f3f5f2;border-color:#dfe5e3}.loop-panel>h2{font-family:"JetBrains Mono",ui-monospace,monospace;letter-spacing:.11em}.loop-status,.phase-card,.tool-card{border-radius:12px;border-color:#dfe5e3;background:#fffefc}.loop-status{padding:17px;box-shadow:0 8px 26px #17382b08}.phase-list li:not(:last-child)::after{background:#d8e1dc}.phase-list .active i{border-color:#0b8f83;background:#e8f5f1;color:#0b8f83}.tool-row{border-radius:9px;background:#f3f6f3}
|
||||
.monitor-dialog{border-radius:12px;background:#fffefc}.monitor-back{border-radius:10px;background:#0b8f83}
|
||||
.modal>div{position:relative}
|
||||
.dialog-close{position:absolute;top:12px;right:12px;z-index:6;width:38px;height:38px;display:grid;place-items:center;padding:0;border-radius:10px;background:#fff;color:#43544d;border:1px solid #d8e0dc;font-size:23px;line-height:1;box-shadow:0 5px 16px #18382b14}.dialog-close:hover{background:#f1f5f2;color:#102a20}.monitor-dialog>.dialog-close{position:sticky;float:right;top:0;margin-left:10px}.monitor-dialog>.monitor-back{margin-right:0}
|
||||
.skill-list-head{position:sticky;top:-10px;z-index:2;display:flex;align-items:center;justify-content:space-between;margin:-10px -10px 6px;padding:12px 12px 9px;background:#fff;border-bottom:1px solid #e5ebe8}.skill-list-head>strong{font-size:12px}.skill-list-head>button{width:28px;height:28px;padding:0;border-radius:8px;font-size:18px}
|
||||
.feature-overlay{position:fixed;inset:0;z-index:900;padding:22px;background:#0b17245c;backdrop-filter:blur(5px)}.feature-panel{position:relative;width:min(1080px,100%);height:100%;margin:auto;overflow:auto;padding:28px;background:#fbfcf9;color:#102436;border:1px solid #dce4df;border-radius:14px;box-shadow:0 28px 90px #08172252}.feature-panel-head{position:sticky;top:-28px;z-index:5;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:22px;margin:-28px -28px 24px;padding:22px 28px;background:#fbfcf9f2;border-bottom:1px solid #e1e7e3;backdrop-filter:blur(12px)}.feature-panel-head small{font-family:"JetBrains Mono",ui-monospace,monospace;color:#74817b;font-size:10px;letter-spacing:.12em}.feature-panel-head h1{margin:3px 0 0;font-size:25px}.feature-panel-head .dialog-close{position:static}.feature-back{background:#fff;color:#284036}.history-list{display:grid;gap:12px;max-width:820px;margin:auto}.history-list article{padding:18px 20px;background:#fff;border:1px solid #dfe6e2;border-radius:11px;box-shadow:0 8px 26px #17382b08}.history-list article small{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:#0b8f83}.history-list article p{margin:8px 0 0;white-space:pre-wrap}.panel-empty{min-height:360px;display:grid;place-content:center;justify-items:center;text-align:center;color:#718078}.panel-empty h2{margin:14px 0 4px;color:#21372e;font-size:20px;text-transform:none;letter-spacing:0}.panel-empty p{margin:4px 0}.report-panel{max-width:820px;margin:auto;text-align:center}.report-panel>img{display:block;width:100%;height:auto;border:1px solid #dfe6e2;border-radius:12px;background:#fff;box-shadow:0 16px 42px #17382b12}.report-open{display:inline-flex;margin-top:14px;padding:10px 16px;border-radius:9px;background:#0b8f83;color:#fff;text-decoration:none}@media(max-width:680px){.feature-overlay{padding:0}.feature-panel{border-radius:0;padding:18px}.feature-panel-head{top:-18px;margin:-18px -18px 18px;padding:16px 18px;grid-template-columns:1fr auto}.feature-panel-head>.feature-back{grid-column:1/3;justify-self:start}.feature-panel-head h1{font-size:21px}}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { modelStorageKey, readSavedModel, saveModel } from "../src/model-storage.js";
|
||||
import type { ModelConnectionInput } from "@agent-studio/shared";
|
||||
afterEach(() => localStorage.clear());
|
||||
const config = { providerId: "test", modelId: "test", apiKey: "test-secret" } as ModelConnectionInput;
|
||||
it("never persists the API key", () => {
|
||||
saveModel(config);
|
||||
expect(localStorage.getItem(modelStorageKey)).not.toContain("test-secret");
|
||||
expect(readSavedModel()?.apiKey).toBeUndefined();
|
||||
expect(config.apiKey).toBe("test-secret");
|
||||
});
|
||||
it("removes keys from legacy saved configurations", () => {
|
||||
localStorage.setItem(modelStorageKey, JSON.stringify(config));
|
||||
expect(readSavedModel()?.apiKey).toBeUndefined();
|
||||
expect(localStorage.getItem(modelStorageKey)).not.toContain("test-secret");
|
||||
});
|
||||
Reference in New Issue
Block a user