diff --git a/agent-studio-mini/.agents/skills/artifact-template-sw-ads-tiktok/SKILL.md b/agent-studio-mini/.agents/skills/artifact-template-sw-ads-tiktok/SKILL.md index 19e4370..06a0f72 100644 --- a/agent-studio-mini/.agents/skills/artifact-template-sw-ads-tiktok/SKILL.md +++ b/agent-studio-mini/.agents/skills/artifact-template-sw-ads-tiktok/SKILL.md @@ -10,10 +10,10 @@ Create a presentation from this template. Keep the reference file unchanged. ## Workflow 1. Read `artifact-template.json` and resolve its paths relative to this skill directory. -2. Load [@presentations](plugin://presentations@openai-primary-runtime) and invoke its reference/template workflow with the retained file. +2. Call `inspect_ppt_template` to read the retained template and its editable text IDs. Beryl has local PPT tools; do not invoke unavailable Codex plugins. 3. Treat the user's prompt and available sources as the content input. Do not invent facts merely to fill a template slot. -4. Clone or import the reference instead of replacing its visual system with generic defaults. -5. Render and verify the finished presentation, then return the final artifact. +4. Call `generate_pptx` with template `swads` and replacements containing inspected IDs and exact original text. For native tables use tableCells with inspected table ID, zero-based row/column and text. The tool imports a copy and edits existing text, preserving the source. It retains all slides; replace irrelevant source content and do not pretend unsupported image/chart replacement is available. +5. The tool validates and renders the file and returns artifactId. Deliver the UI download link. Missing template, unknown IDs or validation failures are errors, never substitute an outline or plain template without user approval. ## Fidelity diff --git a/agent-studio-mini/.agents/skills/artifact-template-sw-ads-tiktok/assets/reference.pptx b/agent-studio-mini/.agents/skills/artifact-template-sw-ads-tiktok/assets/reference.pptx new file mode 100644 index 0000000..c49a6a0 Binary files /dev/null and b/agent-studio-mini/.agents/skills/artifact-template-sw-ads-tiktok/assets/reference.pptx differ diff --git a/agent-studio-mini/.agents/skills/demo-report/SKILL.md b/agent-studio-mini/.agents/skills/demo-report/SKILL.md new file mode 100644 index 0000000..d621efc --- /dev/null +++ b/agent-studio-mini/.agents/skills/demo-report/SKILL.md @@ -0,0 +1,10 @@ +--- +name: demo-report +description: 使用固定演示数据验证读取、分析、生成报告的闭环;只用于 demo-account,不代表真实广告数据。 +--- +# 演示报告 + +1. 调用 mock_ads_metrics,accountId=demo-account、days=7。禁止访问真实广告账户。 +2. 根据工具数据计算 CTR、CPA、ROAS,分析每个 Campaign,指出 Prospecting Video 的 ROAS 最低。不可编造指标。 +3. 调用 save_report_draft 保存 Markdown 报告,等待用户批准。报告必须标明“演示数据”,包含账户、花费、曝光、点击、转化、收入、CTR、CPA、ROAS、Campaign 分析和 3 条建议。 +4. 用 read 读取保存的 report.md,核对以上字段及数值;不一致则报告验收失败,不得声称交付成功。 diff --git a/agent-studio-mini/.dockerignore b/agent-studio-mini/.dockerignore new file mode 100644 index 0000000..b521851 --- /dev/null +++ b/agent-studio-mini/.dockerignore @@ -0,0 +1,31 @@ +** +!package.json +!pnpm-lock.yaml +!pnpm-workspace.yaml +!tsconfig.base.json +!eslint.config.mjs +!apps/ +!apps/** +!packages/ +!packages/** +!scripts/ +!scripts/** +!fixtures/ +!fixtures/ads-account.json +!sandbox/ +!sandbox/** +!Dockerfile +!.agents/ +!.agents/skills/ +!.agents/skills/demo-report/ +!.agents/skills/ads-analysis/ +!.agents/skills/artifact-template-sw-ads-tiktok/ +!.agents/skills/demo-report/** +!.agents/skills/ads-analysis/** +!.agents/skills/artifact-template-sw-ads-tiktok/** +**/node_modules +**/dist +**/workspace +**/.env* +**/.git +**/*.log diff --git a/agent-studio-mini/.gitignore b/agent-studio-mini/.gitignore index 598bbcc..2462aab 100644 --- a/agent-studio-mini/.gitignore +++ b/agent-studio-mini/.gitignore @@ -4,3 +4,5 @@ coverage/ .env *.tsbuildinfo workspace/sessions/ +workspace/monitoring/ +workspace/*-recovered.md diff --git a/agent-studio-mini/Dockerfile b/agent-studio-mini/Dockerfile new file mode 100644 index 0000000..78075ed --- /dev/null +++ b/agent-studio-mini/Dockerfile @@ -0,0 +1,34 @@ +FROM node:24-bookworm-slim AS build +WORKDIR /app +RUN npm install --global pnpm@11.19.0 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/harness/package.json packages/harness/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN pnpm install --frozen-lockfile +COPY apps ./apps +COPY packages ./packages +COPY scripts ./scripts +COPY fixtures/ads-account.json ./fixtures/ads-account.json +COPY sandbox ./sandbox +COPY .agents/skills/demo-report ./.agents/skills/demo-report +COPY .agents/skills/ads-analysis ./.agents/skills/ads-analysis +COPY .agents/skills/artifact-template-sw-ads-tiktok ./.agents/skills/artifact-template-sw-ads-tiktok +COPY tsconfig.base.json eslint.config.mjs ./ +RUN pnpm build && mkdir -p /app/workspace /app/.agents/skills /app/.pi/skills && chown node:node /app/workspace + +FROM node:24-bookworm-slim AS agent +WORKDIR /app +COPY --from=build /app /app +RUN chown node:node /app/workspace +USER 1000:1000 +ENV NODE_ENV=production PORT=3001 HOST=127.0.0.1 +EXPOSE 5173 +CMD ["node", "sandbox/entry.mjs"] + +FROM node:24-bookworm-slim AS egress +WORKDIR /proxy +COPY sandbox/egress.mjs ./egress.mjs +USER 1000:1000 +CMD ["node", "egress.mjs"] diff --git a/agent-studio-mini/MONITORING.md b/agent-studio-mini/MONITORING.md new file mode 100644 index 0000000..cf3e0ce --- /dev/null +++ b/agent-studio-mini/MONITORING.md @@ -0,0 +1,25 @@ +# SW Ads Campaign 预警 + +在 Agent 顶部点击「SW Ads 预警」,立即检查全部可访问的 TikTok 账户。每次重新发现账户,按账户时区和币种判断,支持账户筛选。当前规则仅评估 commerce_shop Campaign,非电商记录显示排除数量,不代表正常。 + +读取各账户时逐页显式指定并校验账户,结束后恢复原账户。与本 Agent 任务互斥;外部 SW Ads 页面同时切换账户仍可能使检查失败,请等待完成。单个账户失败独立展示,不冒用旧结果。 + +- 按每个 Campaign 的 commerce_summary.target_roi 判断,不使用统一 2.5。 +- 读取账户时区下最近两个完整业务日;ROI 为 gross_revenue / spend,不等于净利润。没有毛利、退款等成本数据时不判断真实亏损。 +- 默认最低消耗 100(账户币种)、5 单、CTR 比较最低 1000 曝光、同步有效期 12 小时;这是可配置的保守启发式,不是平台官方规则。 +- 新建不足 72 小时、样本不足、目标缺失、同步过期、报表缺失或有待发布配置时不建议调预算。 +- 连续达标且最新 ROI 超目标 20% 列为高潜候选;低于目标 80% 列为不达标;另检查无订单、CTR 下滑、CPA 上升。预算、暂停、目标复核和素材建议全部需要人工判断。 +- 每小时自动检查默认关闭,在页面勾选并保存后启用,后端必须持续运行。页面每 30 秒刷新显示;没有邮件或飞书推送。 +- 结果、设置、已处理状态保存在 workspace/monitoring/state.json(不入 Git)。重启后恢复;同日同 Campaign/目标/类型不重复计为新增。 +- Agent 可通过只读 swads_alerts 工具解释最近一次检查,不能据此执行真实投放操作。工具不主动刷新,使用前应在预警中心检查最新数据。 + +当前为本机单进程、同一授权身份下多账户版本,不是多租户生产服务。不要未经鉴权与访问控制直接公开接口。没有分布式调度、不可变审计历史或外部通知;本次没有改变既有沙盒能力。 +# 素材疲劳预警 + +预警中心的“素材疲劳预警”页和 Agent 的 `swads_alerts` 工具共用持久化结果。只读检查所有可访问 TikTok 账户中的电商视频/帖子,按账户、Campaign、素材 ID、身份和投放口径隔离,不混入商品卡。 + +按账户业务日期:最近 3 个完整日对比此前 7 日。CTR 用窗口总商品点击 / 总商品曝光重算。近期要求 3 个有曝光日、基线至少 3 日,两窗口均达到曝光门槛,基线至少 30 点击。CTR 相对下降至少 25% 提示“素材衰退”;同时 6 秒观看率(6秒观看数 / 播放数)下降至少 20% 才提示“疑似疲劳”。这些是可配置初始启发式阈值,不是官方标准或因果证明。 + +观看数据缺失或播放量为零不能当作观看率为零。缺少准确频次,因此频次不参与判断。GMV Max 的 ADS_AND_ORGANIC 包含自然流量,不能当作纯广告效果;新素材、覆盖不足、同步过期、未发布修改不能触发疲劳判定。未自动排除受众、预算、目标、价格、库存及归因变化,必须人工核实。基线只要求样本量,不表示它历史上已盈利。 + +逐日读取10个完整业务日,避免整个窗口触及行数上限。单日截断(含达到 5000 行上限)、重复行、错账户、错误日期或字段会明确报素材检查失败;不影响已成功读取的 Campaign 结果,但整体不能显示全部成功。没有 Campaign 关联的素材库视频计入排除数,不当作投放视频评估。结果和已处理状态保存在原监控状态文件;旧设置自动补齐新阈值。没有任何自动暂停、调预算或替换素材操作。 diff --git a/agent-studio-mini/README.md b/agent-studio-mini/README.md index 292d0a8..23a187a 100644 --- a/agent-studio-mini/README.md +++ b/agent-studio-mini/README.md @@ -13,29 +13,24 @@ flowchart LR L --> S[Skills / Workspace / ModelRuntime] ``` -## 功能介绍与页面展示 - -Beryl 是面向广告数据和素材工作的 Agent:通过自然语言任务,完成读取数据、分析、生成报告和验收。当前配置使用 `Qwen3.8-27B-FP8` 模型标识,通过 Anthropic 兼容接口接入;模型负责理解与生成,Agent + Harness 负责工具调用、状态管理及交付检查。 - -![Beryl Agent 页面功能示意](./agent-page-overview.svg) - -> 页面功能示意,非运行截图;不包含真实账户、聊天记录或密钥。 - -- **对话与 Skills**:自然语言交互,加载广告分析、日报、周报、素材脚本和报告等能力。 -- **运行进度**:展示分析阶段、工具执行、按需审批和结果整理。 -- **报告与 PPT**:按任务要求生成产物并验收;依赖有效模型连接和相关运行环境。 -- **SW Ads 多账户预警**:检查所有授权 TikTok 账户,使用各 Campaign 实际目标 ROI,支持账户筛选、异常提示及人工建议。非电商记录暂不套用电商 ROI 规则。 -- **安全边界**:预警不自动修改广告;数据缺失或过期时明确提示,不把失败当完成。 - -[查看完整模型介绍与使用方式](./AGENT-OVERVIEW.md) · [获取最新源码包](./BERYL-RELEASE-20260909.md) - -> 版本说明:以上功能对应最新封装版本;仓库旧的展开源码未整体替换,以下保留原课堂版说明。 - ## 快速开始 要求 Node.js 20+ 与 pnpm。运行 `pnpm install`,复制 `.env.example` 为 `.env` 并按需填写,然后 `pnpm dev`;Web 为 ,API 为 。也可不写环境密钥,在顶部“模型设置”中提交 Provider、API、Base URL、Model ID、Vision 能力与 API Key。密钥只在当前服务进程的 Session 内存中保存,不回传、不写 Web Storage 或 Pi 全局文件。 -课堂 Demo:配置模型,发送“请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。”可再附图并说明图片仅为辅助信号。`save_report_draft` 会暂停并等待批准;Approve 写入 Session 固定 `report.md`,Deny/Timeout 不写文件。 +课堂 Demo:配置模型,发送“请使用 demo-report Skill,读取 demo-account 最近 7 天演示数据,分析 Campaign,生成含 3 条建议的报告草稿。保存后读取报告并核对指标,明确验收是否通过。”该流程只使用固定演示数据,不查询客户账户。`save_report_draft` 会暂停并等待批准;Approve 写入 Session 固定 `report.md`,Deny/Timeout 不写文件。自动验收可在 apps/server 目录执行 `pnpm exec tsx ../../scripts/verify-demo.mts`;脚本仅自动批准自己新建会话的演示报告写入。 + +## 页面与功能展示 + +![Beryl Agent 工作台](./agent-page-overview.png) + +- **任务详情页**:历史任务可查看当前会话中的用户指令与 Agent 结果;所有详情页均提供“返回工作台”、右上角关闭、点击外部关闭和 `Esc` 退出。 +- **日报 / 周报**:Agent 完成 `save_report_image` 后,报告图片会直接出现在对话和对应报告页;支持页面内预览及打开大图。 +- **转化漏斗**:日报和周报按“曝光 → 点击 → 访问 → 加购 → 购买”展示关键指标,商品曝光包含所有视频与商品页曝光的汇总口径。 +- **快捷任务**:日报、周报、投放分析和素材策划卡片点击后仅带入指令,不会自动提交固定问题。 +- **运行详情**:右侧展示接收任务、分析规划、工具执行、人工确认和结果整理状态,并保留工具调用与错误诊断信息。 +- **技能目录**:展示已加载 Skill 及诊断状态,支持关闭按钮和点击外部退出。 + +> 仓库展示图不包含 API Key、真实账户凭据或浏览器记录。模型凭据只允许放入本机 `.env` 或当前服务进程,不提交到 Git。 ## 职责与行为 diff --git a/agent-studio-mini/agent-page-overview.png b/agent-studio-mini/agent-page-overview.png index 5f313f5..75bdc75 100644 Binary files a/agent-studio-mini/agent-page-overview.png and b/agent-studio-mini/agent-page-overview.png differ diff --git a/agent-studio-mini/apps/server/src/app.ts b/agent-studio-mini/apps/server/src/app.ts index 42a735e..0035340 100644 --- a/agent-studio-mini/apps/server/src/app.ts +++ b/agent-studio-mini/apps/server/src/app.ts @@ -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 }; diff --git a/agent-studio-mini/apps/server/src/fatigue.ts b/agent-studio-mini/apps/server/src/fatigue.ts new file mode 100644 index 0000000..9a9fa6c --- /dev/null +++ b/agent-studio-mini/apps/server/src/fatigue.ts @@ -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 & { 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, 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(); const seen=new Set(); 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(dateto) 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({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.fatigueViewDrop) return out("suspected_fatigue","商品点击率与6秒观看率同时衰退:疑似素材疲劳,需人工排除其他原因"); + return out("creative_decline",viewDrop===null?"商品点击率明显下降;缺少有效观看数据及准确频次,疲劳证据不足":"商品点击率明显下降,但观看衰退未达门槛,暂不判定疲劳"); +} +export type FatigueResult=ReturnType & {key:string;handled:boolean;period:string;account:{id:string;tenant:string;name:string;timezone:string;currency:string}}; diff --git a/agent-studio-mini/apps/server/src/index.ts b/agent-studio-mini/apps/server/src/index.ts index 60b133a..7723d8e 100644 --- a/agent-studio-mini/apps/server/src/index.ts +++ b/agent-studio-mini/apps/server/src/index.ts @@ -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 }); diff --git a/agent-studio-mini/apps/server/src/monitor.ts b/agent-studio-mini/apps/server/src/monitor.ts new file mode 100644 index 0000000..6f75920 --- /dev/null +++ b/agent-studio-mini/apps/server/src/monitor.ts @@ -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; +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 & { 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) { + 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(); + 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; + 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,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={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, org: string) { + const campaigns: z.infer[] = []; + 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}; + } +} diff --git a/agent-studio-mini/apps/server/tests/fatigue.test.ts b/agent-studio-mini/apps/server/tests/fatigue.test.ts new file mode 100644 index 0000000..9ae95c1 --- /dev/null +++ b/agent-studio-mini/apps/server/tests/fatigue.test.ts @@ -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("账户"); +}); diff --git a/agent-studio-mini/apps/server/tests/monitor.test.ts b/agent-studio-mini/apps/server/tests/monitor.test.ts new file mode 100644 index 0000000..22d26c0 --- /dev/null +++ b/agent-studio-mini/apps/server/tests/monitor.test.ts @@ -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={})=>{ + 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});} +}); diff --git a/agent-studio-mini/apps/web/src/App.tsx b/agent-studio-mini/apps/web/src/App.tsx index ae36840..396543d 100644 --- a/agent-studio-mini/apps/web/src/App.tsx +++ b/agent-studio-mini/apps/web/src/App.tsx @@ -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}) { + const composer = unstable_useComposerInput(); + return
+ {quickTasks.map(([title,copy,prompt,Icon],index)=>)} +
; +} 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( export function App() { const [monitorOpen,setMonitorOpen]=useState(false); + const [activePanel,setActivePanel]=useState<"history"|"daily"|"weekly"|"creative"|"prelaunch"|"library"|null>(null); + const skillDrawerRef=useRef(null); const [session, setSession] = useState(""); useEffect(() => { if (session) localStorage.setItem("beryl-session-id", session); }, [session]); const [skills, setSkills] = useState([]); @@ -71,6 +89,7 @@ export function App() { const [attachmentError, setAttachmentError] = useState(""); const es = useRef(undefined); const messagesRef = useRef(null); + const composerInputRef = useRef(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 ( - {monitorOpen&&setMonitorOpen(false)}/>} + {monitorOpen&&setMonitorOpen(false)}/>} + {activePanel&&setActivePanel(null)}/>}
-
运营工作台

今天想先做什么?

-
+
BERYL INTELLIGENCE · 运营工作台

今天想先做什么?

Beryl 已准备好读取广告数据、分析表现并生成报告。

+
数据已连接
@@ -405,23 +436,13 @@ export function App() { )) ) : (
-
- {([ - ["日报", "汇总昨天表现与关键异常", "生成 Lemonz Home 昨日投放日报", FileChartColumn], - ["周报", "看清趋势、漏斗与行动建议", "生成 Lemonz Home 上周投放周报", BarChart3], - ["分析投放", "定位商品与视频转化问题", "分析最近三周厨具投放效果", PackageSearch], - ["策划新素材", "从数据洞察生成创意方向", "为厨具规划下一批广告素材", Lightbulb], - ] as const).map(([title, copy, prompt, Icon]) => ( - - ))} -
+
也可以直接在下方输入你想完成的任务
)}
+
新任务输入自然语言即可开始
{pendingImages.length > 0 && (
{pendingImages.map((image) => ( @@ -444,6 +465,7 @@ export function App() {

)} void onPasteImage(event)} />
+ Enter 发送 · Shift + Enter 换行 @@ -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(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
{if(event.target===event.currentTarget)onClose();}}> +
event.stopPropagation()}> +
BERYL WORKSPACE

{panelTitles[kind]}

+ {kind==="history"&&
{messages.length?messages.map(message=>
{message.role==="user"?"你的任务":"Agent 结果"}

{message.text||"处理中…"}

):

暂无历史任务

完成任务后,当前会话记录会显示在这里。

}
} + {(kind==="daily"||kind==="weekly")&&
{reportReady===null?

正在读取报告…

:reportReady?<>{`${panelTitles[kind]}图片`}/打开大图:

暂无{panelTitles[kind]}

返回工作台生成{panelTitles[kind]}后,会自动出现在这里。

}
} + {(kind==="creative"||kind==="prelaunch"||kind==="library")&&

{panelTitles[kind]}

该功能已打开。返回工作台输入任务,Agent 会调用对应技能处理。

} +
+
; +} + const toolLabels: Record = { swads_whoami: "连接 SW Ads", metrics_catalog: "读取指标目录", @@ -752,6 +799,13 @@ function Settings({ const [input, setInput] = useState( () => 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(); + }} > -
+
event.stopPropagation()}> +

模型连接