From ce813ed358125a73ef664bd961db2c35502e77e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=B6=E4=B9=8B?= Date: Thu, 13 Aug 2026 14:33:41 +0800 Subject: [PATCH] docs: add product experience officer best practice and demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 时之 --- .../product-experience-officer/index.md | 92 + demos/product-experience-officer/README.md | 57 + .../app/api/qca/route.ts | 2145 +++++++++++++++++ .../app/api/runs/route.ts | 126 + .../app/report-markdown.tsx | 380 +++ .../db/evaluations.ts | 518 ++++ demos/product-experience-officer/db/index.ts | 13 + demos/product-experience-officer/db/schema.ts | 32 + .../db/supabase-admin.ts | 28 + .../lib/evaluation-prompt.ts | 105 + .../product-experience-officer/lib/product.ts | 161 ++ .../lib/qca-report-finality.ts | 302 +++ .../lib/run-owner.ts | 70 + .../lib/test-access.ts | 490 ++++ demos/product-experience-officer/package.json | 11 + 15 files changed, 4530 insertions(+) create mode 100644 content/zh-CN/best-practices/product-experience-officer/index.md create mode 100644 demos/product-experience-officer/README.md create mode 100644 demos/product-experience-officer/app/api/qca/route.ts create mode 100644 demos/product-experience-officer/app/api/runs/route.ts create mode 100644 demos/product-experience-officer/app/report-markdown.tsx create mode 100644 demos/product-experience-officer/db/evaluations.ts create mode 100644 demos/product-experience-officer/db/index.ts create mode 100644 demos/product-experience-officer/db/schema.ts create mode 100644 demos/product-experience-officer/db/supabase-admin.ts create mode 100644 demos/product-experience-officer/lib/evaluation-prompt.ts create mode 100644 demos/product-experience-officer/lib/product.ts create mode 100644 demos/product-experience-officer/lib/qca-report-finality.ts create mode 100644 demos/product-experience-officer/lib/run-owner.ts create mode 100644 demos/product-experience-officer/lib/test-access.ts create mode 100644 demos/product-experience-officer/package.json diff --git a/content/zh-CN/best-practices/product-experience-officer/index.md b/content/zh-CN/best-practices/product-experience-officer/index.md new file mode 100644 index 0000000..2c53589 --- /dev/null +++ b/content/zh-CN/best-practices/product-experience-officer/index.md @@ -0,0 +1,92 @@ +--- +schema_version: 1 +slug: product-experience-officer +title: 让 Agent 替你把产品从头点一遍 +summary: 给 Cloud Agent 装上浏览器、锁死只读权限,让它自己去读文档、点控制台、打 API,最后交回一份说得清证据来源的体验报告。 +type: best-practice +category: evaluation-reliability +tags: + - browser + - tool-permission + - evals + - verification + - session +author: + name: 时之 + github: yefengzi7 +locale: zh-CN +--- + +## 适用场景与边界 + +每次产品要发版,总得有人从新用户视角把东西重新点一遍:文档看得懂吗?控制台的按钮点下去有反应吗?API 的报错跟文档写的一样吗?这活儿枯燥、重复,而且做久了人会习惯性跳过——因为你太熟了,闭着眼都知道下一步点哪。 + +Agent 不会「太熟」。给它装上浏览器能力,它就会老老实实按你给的路径走一遍,然后把看到的东西写下来。 + +有意思的是,这种「不熟」反而是它最大的价值。我拿它去巡检自家产品,它照着官方文档说的 401 错误格式去核对真实响应,结果发现响应体的字段结构跟文档描述压根不是一回事——这种问题老手会下意识跳过,因为「我知道实际长什么样」。 + +不过这套玩法有几个前提,不满足就别硬上: + +- **产品得有公开能进的入口。** 文档站、控制台、公开 API,至少得有能被看见的东西。全靠内网或者必须登录才能看到任何东西,Agent 无从下手。 +- **默认只读。** 巡检的目的是发现问题,不是把被测系统改了。这条不只是「建议」——它决定了你敢不敢把这东西挂上定时任务自动跑。 +- **每条结论都得能问出「你怎么知道的」。** 这是最容易翻车的地方,后面细说。 + +也说说什么时候别用它。如果目标产品必须登录、而你手上没有合法的测试账号,那就到此为止——不要给 Agent 一个真账号让它「想办法进去」。遇到登录页的正确反应是停下来喊人,而不是自己琢磨。 + +我实测时就撞上了这个:控制台整个命名空间在未登录时全被重定向到登录页。Agent 到这儿就停了,在报告里老实写「控制台未执行」。这个结果不好看,但它是真的——比编一段「控制台体验流畅」有价值得多。 + +下面这张图就是它的行为边界,简单到一句话能说完:能看的随便看,要动手或要登录就停。 + +```mermaid +flowchart LR + A[开始巡检] --> B[导航与读取] + B --> C{要登录或要写入} + C -->|不需要| D[继续收集证据] + C -->|需要| E[停下来交给人] + D --> F[产出报告] +``` + +## 推荐做法 + +一句话总结:**能力给满,权限锁死,说话算话。** + +能力给满是因为你想要它真的去点网页,而不是抓个 HTML 回来糊弄你。权限锁死是因为你不想半夜被告知它把测试环境的数据改了。说话算话是最容易被忽略的一条——报告里每句话都得对得上一次真实操作。 + +| 决策 | 推荐方式 | 原因 | +|---|---|---| +| 浏览器能力 | 显式启用浏览器工具集,并带上它要求的 Beta 头 | 少一个头就没有真实网页操作,也没有实时预览 | +| 写入类内置工具 | 直接配拒绝策略 | 只读巡检不需要它们,能关就关 | +| 你自己的令牌 | 只放在当前会话内存里,随请求发一次 | 不落库、不写日志、不进环境变量,关掉页面就没了 | +| 被测产品的账号密码 | 只写进平台的只写凭证接口,会话只拿到一个引用 | 明文永远不进 Prompt、日志、报告和数据库 | +| 碰到登录页 | 停下来,让人在浏览器预览里自己登 | 浏览器工具没有「安全输入密码」这种协议,别让 Agent 碰 | +| 多用户的历史记录 | 用一个随机凭据隔离,不从任何身份信息推导 | 清掉它旧记录就看不见了,简单有效 | + +有几个点是我踩过才知道的: + +**浏览器工具的版本号和它要求的 Beta 头是一套东西。** 升级一个忘了另一个,表现就是工具「看起来配了但没生效」,而且不一定报错——它只是安静地不干活。改的时候一起改,测的时候一起测。 + +**分清「我真点了」和「我只是看了文档」。** 这是整件事的信誉基础。Agent 完全有能力抓个网页然后把内容写得像亲自操作过一样。所以规则得写死:没真跑浏览器,报告里必须标明。我那轮巡检里 Agent 就自己区分得挺清楚——哪些是浏览器实操、哪些只是抓的文档,分开列的。 + +**报告要优先拿平台交付的原件。** 如果拿不到、退回去拼接 Agent 的消息,你会得到一些很滑稽的东西——比如报告第一行是模型的开场白「好了,以下是完整报告」。这不影响内容,但它说明产物交付那步没走通,值得查。 + +## 验证与维护 + +这套系统最讽刺的地方是:**它本身也需要被巡检。** 一个声称「我会给你证据」的东西,你凭什么信它给的证据是真的? + +所以每轮跑完,至少确认三件事: + +**浏览器是不是真的动了。** 翻证据记录,看有没有导航、点击、截图这类真实的浏览器调用。如果全是网页抓取,那报告里所有关于控制台的结论都得打问号——不管它写得多具体。 + +**只读到底守住了没有。** 一轮干净的只读巡检应该是「零写入、零临时凭证、无需清理」。任何一项对不上,说明契约漏了,先别管报告内容,先去查权限配置。 + +**报告是完整的吗。** 看它是从交付原件来的,还是拼消息拼出来的。后者往往意味着中间出过状况。 + +长期维护上,我最想提醒的是一个**不报错的坑**:如果证据统计只取最新一批事件、又不做分页,那么跑得越久,早期的工具调用就会被新事件挤出统计窗口。 + +我盯着数据库看过整个过程,工具调用数是一路往下掉的——不是它变懒了,是证据被挤没了。这种问题最阴险的地方在于它安安静静,不报错、不崩溃,你只会觉得「数字有点怪」。对一个把「证据齐全」当卖点的系统来说,这比直接挂掉严重得多。把它当成长期回归项盯着。 + +## 可选:Demo 源码 + +Demo 是个能直接跑的最小骨架:构造只读访问摘要、开一个会话、发一次巡检任务、轮询证据,最后自己检查只读契约有没有守住。核心那个访问契约模块是从真实项目里搬出来的,不是为演示重写的简化版。跑法和清理都在 README 里。 + +[查看 Demo 源码](https://github.com/QoderAI/cloud-agents-cookbook/tree/main/demos/product-experience-officer) diff --git a/demos/product-experience-officer/README.md b/demos/product-experience-officer/README.md new file mode 100644 index 0000000..de41f84 --- /dev/null +++ b/demos/product-experience-officer/README.md @@ -0,0 +1,57 @@ +# 产品体验官 · 只读评测核心源码 Demo + +这是「产品体验官」应用里真实的核心源码,按原样从产品仓库抽取而来(不是为演示重写的简化版)。它包含只读评测这套模式的关键实现:域逻辑、持久化、服务端编排和不可信报告的安全渲染。 + +配套文章讲的是「怎么做」,这里是「真实代码长什么样」。 + +## 对应文章 + +- 标题:让 Agent 替你把产品从头点一遍 +- Slug:`product-experience-officer` + +## 目录内容 + +抽取的是真实产品的核心模块,按原仓库结构摆放: + +| 路径 | 作用 | +|---|---| +| `lib/test-access.ts` | 只读契约:校验访问请求、只读模式拒收被测凭证、产出不含敏感信息的摘要、脱敏 | +| `lib/evaluation-prompt.ts` | 按产品包生成评测 Prompt,只依赖只读契约 | +| `lib/product.ts` | 产品包、评测阶段与基线内容 | +| `lib/run-owner.ts` | 随机持有者能力,隔离每个用户的历史 | +| `lib/qca-report-finality.ts` | 判定报告终态与来源 | +| `app/api/qca/route.ts` | 真实的服务端编排:PAT 校验、会话生命周期、事件与证据聚合、报告取回 | +| `app/api/runs/route.ts` | 按持有者隔离的历史查询与统计 | +| `app/report-markdown.tsx` | 把不可信报告当安全 GFM 渲染,禁 HTML、图片降级为占位 | +| `db/` | 按持有者隔离的评测持久化(本地 D1 与 Meoo Supabase 双后端)与 Schema | + +未包含的是纯 UI 骨架(页面、样式、布局)和本地开发脚手架——它们大量出现本地开发地址、对理解这套模式没有帮助。 + +## 前置条件 + +- 阅读源码:无需任何环境。 +- 在真实产品里运行:Node.js 22.13+,以及产品仓库的完整工程(vinext、Cloudflare 绑定、依赖)。本目录是核心源码,供研读与复用,不是可独立构建的完整工程。 + +## 安装与配置 + +无需安装。这些是供阅读和复用的源码模块,导入路径沿用原仓库的 `@/lib`、`@/db` 别名。 + +## 运行 + +本目录不单独构建。要看它们在完整产品里如何运行,参考配套文章描述的流程:给 Agent 配置浏览器工具集与只读契约,发起一次只读评测,轮询证据。`lib/test-access.ts` 是自包含的,可用 `node --experimental-strip-types` 单独导入试跑其校验与脱敏函数。 + +## 验证结果 + +- `lib/test-access.ts`:只读模式传入任何被测凭证都会抛错;`toSafeTestAccessSummary` 的输出只含模式、来源主机、允许效果等非敏感字段。 +- `app/api/qca/route.ts`:只读评测全程零写入;报告优先取交付原件,取不到才退回消息拼接并在来源字段标记。 +- `db/`:每个读写都带 `owner_id` 约束。 + +## 清理资源 + +纯源码,不创建任何资源,无需清理。 + +## 成本与安全 + +- 本目录不含任何真实凭证、密钥或内部地址。 +- 这套代码的设计前提就是:用户 PAT 只在会话内存与单次请求中存在,绝不落库、不写日志、不进环境变量;被测产品凭证只写入平台的只写凭证接口。复用时请保持同样的边界。 +- 只读评测默认不产生副作用;任何写操作都应是显式、可逆、有成本上限的例外。 diff --git a/demos/product-experience-officer/app/api/qca/route.ts b/demos/product-experience-officer/app/api/qca/route.ts new file mode 100644 index 0000000..5766f61 --- /dev/null +++ b/demos/product-experience-officer/app/api/qca/route.ts @@ -0,0 +1,2145 @@ +import { + createEvaluation, + getEvaluation, + updateEvaluation, +} from "@/db/evaluations"; +import { + resolveRunOwner, + withRunOwnerCookie, +} from "@/lib/run-owner"; +import { buildEvaluationPrompt } from "@/lib/evaluation-prompt"; +import { + MAX_REPORT_CONTINUATIONS, + artifactRetrievalWindow, + buildReportContinuationMessage, + reportContinuationDecision, + selectQualifiedReport, +} from "@/lib/qca-report-finality"; +import { + MANUAL_LOGIN_COMPLETED_MARKER, + MANUAL_LOGIN_REQUIRED_MARKER, + TEST_ACCESS_POLICY_VERSION, + buildQcaSessionMetadata, + parseTestAccess, + parseSafeTestAccessSummary, + redactSensitiveText, + toSafeTestAccessSummary, + type CleanupStatus, + type ValidatedTestAccess, +} from "@/lib/test-access"; + +const DEFAULT_CLOUD_BASE_URL = "https://api.qoder.com/api/v1/cloud"; +const DEFAULT_FORWARD_BASE_URL = "https://api.qoder.com/api/v1/forward"; +const ENVIRONMENT_NAME = "PXO Twin Sandbox"; +const TEMPLATE_NAME = "产品体验官"; +const READ_ONLY_AGENT_NAME = "产品体验官|只读 v2"; +const AUTHORIZED_AGENT_NAME = "产品体验官|授权 E2E v2"; +const IDENTITY_EXTERNAL_ID = "pxo-twin-owner"; +const BROWSER_USE_CONTRACT = { + toolType: "browser_toolset_20260714", + betaHeader: "browser-use-2026-07-14", +} as const; +const QCA_DPATH_ENV = "cloud-agent-test17"; + +type RuntimeConfig = { + QODER_CLOUD_BASE_URL?: string; + QODER_FORWARD_BASE_URL?: string; +}; + +type QcaRecord = Record; +type QcaFailureKind = + | "credential_required" + | "session_missing" + | "rate_limited" + | "transient" + | "upstream_error" + | "network"; + +class QcaRequestError extends Error { + readonly status?: number; + readonly kind: QcaFailureKind; + readonly retryable: boolean; + + constructor( + message: string, + options: { + status?: number; + kind: QcaFailureKind; + retryable: boolean; + }, + ) { + super(message); + this.name = "QcaRequestError"; + this.status = options.status; + this.kind = options.kind; + this.retryable = options.retryable; + } +} + +function qcaFailureForStatus(status: number) { + if (status === 401 || status === 403) { + return { kind: "credential_required" as const, retryable: false }; + } + if (status === 404 || status === 410) { + return { kind: "session_missing" as const, retryable: false }; + } + if (status === 429) { + return { kind: "rate_limited" as const, retryable: true }; + } + if (status >= 500) { + return { kind: "transient" as const, retryable: true }; + } + return { kind: "upstream_error" as const, retryable: false }; +} + +function pollFailureContract(error: unknown) { + if (error instanceof QcaRequestError) { + if (error.kind === "credential_required") { + return { + mode: "credential-required", + status: "credential_required", + retryable: false, + httpStatus: error.status || 401, + upstreamStatus: error.status, + message: + "当前 Qoder PAT 无权访问这个 QCA Session。请使用启动该任务时相同账号与工作空间的 PAT。", + }; + } + if (error.kind === "session_missing") { + return { + mode: "live", + status: "session_missing", + retryable: false, + httpStatus: error.status || 404, + upstreamStatus: error.status, + message: + "QCA Session 不存在或已过期;也可能当前 PAT 所属账号或工作空间不同。", + }; + } + if (error.retryable) { + return { + mode: "live", + status: "sync_error", + retryable: true, + httpStatus: error.status === 429 ? 429 : 502, + upstreamStatus: error.status, + message: + error.kind === "rate_limited" + ? "QCA 暂时限流,正在重试同步。" + : "QCA 服务暂时不可用,正在重试同步。", + }; + } + return { + mode: "live", + status: "upstream_error", + retryable: false, + httpStatus: 502, + upstreamStatus: error.status, + message: "QCA 拒绝了本次状态同步请求,请检查 Session 与任务配置。", + }; + } + return { + mode: "live", + status: "sync_error", + retryable: true, + httpStatus: 502, + upstreamStatus: undefined, + message: "QCA 状态同步暂时失败,正在重试。", + }; +} + +function nonterminalSessionStatus(status: unknown) { + return status === "idle" || status === "queued" || status === "running" + ? status + : "running"; +} + +function runtimeConfig() { + const runtime = process.env as RuntimeConfig; + return { + cloudBaseUrl: runtime.QODER_CLOUD_BASE_URL || DEFAULT_CLOUD_BASE_URL, + forwardBaseUrl: + runtime.QODER_FORWARD_BASE_URL || DEFAULT_FORWARD_BASE_URL, + }; +} + +function responseJson( + data: Record, + init?: ResponseInit, +) { + const headers = new Headers(init?.headers); + headers.set("cache-control", "no-store"); + headers.set("content-type", "application/json"); + return new Response(JSON.stringify(data), { ...init, headers }); +} + +function userPat(value: unknown) { + const pat = typeof value === "string" ? value.trim() : ""; + if (!/^pt-[A-Za-z0-9_-]{20,}$/.test(pat)) { + throw new Error("请输入有效的 Qoder PAT"); + } + return pat; +} + +function qcaHeaders(pat: string, idempotencyKey?: string) { + return { + authorization: `Bearer ${pat}`, + "content-type": "application/json", + "x-requested-with": "XMLHttpRequest", + "x-cas-include-extended": "true", + "x-qoder-beta": BROWSER_USE_CONTRACT.betaHeader, + "x-qoder-request-source": "web", + "eagleeye-userdata": `dpath_env=${QCA_DPATH_ENV}`, + "x-biz-info": `mc-sys-aenv=${QCA_DPATH_ENV}`, + ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), + }; +} + +async function qcaJson( + url: string, + pat: string, + init?: RequestInit, +): Promise { + let response: Response | undefined; + const idempotencyKey = + init?.method && init.method !== "GET" ? crypto.randomUUID() : undefined; + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + response = await fetch(url, { + ...init, + headers: { + ...qcaHeaders(pat, idempotencyKey), + ...(init?.headers || {}), + }, + }); + } catch { + throw new QcaRequestError("QCA network request failed", { + kind: "network", + retryable: true, + }); + } + if (response.status !== 429 || attempt === 3) break; + const retryAfter = Number(response.headers.get("retry-after") || 0); + const waitMs = retryAfter + ? retryAfter * 1000 + : 600 * 2 ** attempt + Math.random() * 240; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + + if (!response) { + throw new QcaRequestError("QCA request did not start", { + kind: "network", + retryable: true, + }); + } + + const text = await response.text(); + let data: QcaRecord = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { message: text }; + } + + if (!response.ok) { + const error = + typeof data.error === "object" && data.error + ? (data.error as QcaRecord) + : data; + const message = + typeof error.message === "string" + ? error.message + : `QCA request failed with ${response.status}`; + const failure = qcaFailureForStatus(response.status); + throw new QcaRequestError(message, { + status: response.status, + ...failure, + }); + } + return data; +} + +const TERMINAL_UPDATE_RETRY_DELAYS_MS = [150, 400, 900] as const; + +async function confirmEvaluationUpdate( + update: () => Promise, + wait: (delayMs: number) => Promise = (delayMs) => + new Promise((resolve) => setTimeout(resolve, delayMs)), +) { + for ( + let attempt = 0; + attempt <= TERMINAL_UPDATE_RETRY_DELAYS_MS.length; + attempt += 1 + ) { + try { + if (await update()) return; + } catch { + // A terminal QCA state is not acknowledged until its owner-scoped + // persistence write succeeds; the caller returns a retryable sync error. + } + if (attempt === TERMINAL_UPDATE_RETRY_DELAYS_MS.length) break; + await wait(TERMINAL_UPDATE_RETRY_DELAYS_MS[attempt]); + } + throw new Error("Terminal evaluation state was not persisted"); +} + +function qcaSessionConsoleUrl(sessionId: string) { + return `https://qoder.com/cloud/sessions/${encodeURIComponent(sessionId)}`; +} + +function dataRows(response: QcaRecord) { + return Array.isArray(response.data) + ? response.data.filter( + (item): item is QcaRecord => Boolean(item) && typeof item === "object", + ) + : []; +} + +function recordId(record: QcaRecord | undefined, prefix: string) { + const id = record?.id; + return typeof id === "string" && id.startsWith(prefix) ? id : ""; +} + +function experienceOfficerTools( + mode: "read-only" | "authorized-e2e" = "read-only", + browserUse = true, +) { + const writePolicy = + mode === "authorized-e2e" ? "always_allow" : "always_deny"; + const tools: QcaRecord[] = [ + { + type: "agent_toolset_20260401", + enabled_tools: [ + "Read", + "Write", + "Bash", + "WebFetch", + "WebSearch", + "DeliverArtifacts", + ], + configs: [ + { + name: "WebFetch", + enabled: true, + permission_policy: { type: "always_allow" }, + }, + { + name: "WebSearch", + enabled: true, + permission_policy: { type: "always_allow" }, + }, + { + name: "Write", + enabled: true, + permission_policy: { type: writePolicy }, + }, + { + name: "Bash", + enabled: true, + permission_policy: { type: writePolicy }, + }, + ], + }, + ]; + if (browserUse) { + tools.push({ type: BROWSER_USE_CONTRACT.toolType }); + } + return tools; +} + +function agentSystem(mode: "read-only" | "authorized-e2e") { + const modeRule = + mode === "read-only" + ? "本 Agent 的 Write 与 Bash 已被工具策略拒绝;BrowserUse 仍依赖本只读契约,不得创建、修改或提交被测产品数据。" + : "本 Agent 仅可在用户显式授权的测试边界内创建或更新带指定前缀的临时资源,并清理自己创建的测试数据。"; + return `你是产品体验官。先完成真实产品体验,再给出判断。 + +工作原则: +1. 结论必须绑定可复现证据;没有看到、没有调用、没有执行的内容不得写成实测。 +2. ${modeRule} +3. 永久删除、购买/支付、权限提升、凭证变更、对外消息、生产数据修改与不可逆操作在任何模式都禁止。 +4. 秘密值只能通过 QCA Vault 注入的环境变量引用;不得读取、打印、回显、写入消息、报告、文件、截图或工具参数。 +5. BrowserUse 没有不透明秘密输入能力;不得把密码或 Token 输入 BrowserUse。需要账号登录时执行人工接管检查点。 +6. 每条问题使用“场景—操作—预期—实际—证据—影响—建议”结构,交付中文 Markdown 与 IM 摘要。 +7. 报告必须以“覆盖声明”开头;只有登录、核心旅程和清理全部成功,才能写“端到端已验证”。`; +} + +function hasBrowserUse(record: QcaRecord | undefined) { + return ( + Array.isArray(record?.tools) && + record.tools.some( + (tool) => + Boolean(tool) && + typeof tool === "object" && + (tool as QcaRecord).type === BROWSER_USE_CONTRACT.toolType, + ) + ); +} + +async function ensureForwardResources( + config: ReturnType, + pat: string, +) { + const [environmentResponse, identityResponse, templateResponse] = + await Promise.all([ + qcaJson(`${config.cloudBaseUrl}/environments?limit=100`, pat), + qcaJson( + `${config.forwardBaseUrl}/identities?external_id=${encodeURIComponent(IDENTITY_EXTERNAL_ID)}&limit=10`, + pat, + ), + qcaJson( + `${config.forwardBaseUrl}/templates?status=active&limit=100`, + pat, + ), + ]); + + let environment = dataRows(environmentResponse).find( + (item) => item.name === ENVIRONMENT_NAME, + ); + let environmentId = recordId(environment, "env_"); + let provisioned = false; + + if (!environmentId) { + environment = await qcaJson( + `${config.cloudBaseUrl}/environments`, + pat, + { + method: "POST", + body: JSON.stringify({ + name: ENVIRONMENT_NAME, + description: + "产品体验官的隔离执行环境;默认允许访问被测产品文档与公开 API。", + config: { + type: "cloud", + networking: { type: "unrestricted" }, + }, + metadata: { app: "pxo-twin", managed_by: "pxo-twin-site" }, + }), + }, + ); + environmentId = recordId(environment, "env_"); + provisioned = true; + } + + if (!environmentId) throw new Error("QCA did not return an environment id"); + + let identity = dataRows(identityResponse).find( + (item) => item.external_id === IDENTITY_EXTERNAL_ID, + ); + let identityId = recordId(identity, "idn_"); + + if (!identityId) { + identity = await qcaJson( + `${config.forwardBaseUrl}/identities`, + pat, + { + method: "POST", + body: JSON.stringify({ + external_id: IDENTITY_EXTERNAL_ID, + name: "产品体验官用户", + metadata: { channel: "web", app: "pxo-twin" }, + }), + }, + ); + identityId = recordId(identity, "idn_"); + provisioned = true; + } + + if (!identityId) throw new Error("QCA did not return an identity id"); + + let template = dataRows(templateResponse).find( + (item) => item.name === TEMPLATE_NAME, + ); + let templateId = recordId(template, "tmpl_"); + + if (!templateId) { + const models = await qcaJson(`${config.cloudBaseUrl}/models`, pat); + const model = dataRows(models).find((item) => item.id === "ultimate") + ?? dataRows(models)[0]; + const modelId = + typeof model?.id === "string" ? model.id : ""; + if (!modelId) throw new Error("No enabled QCA model is available"); + + template = await qcaJson( + `${config.forwardBaseUrl}/templates`, + pat, + { + method: "POST", + body: JSON.stringify({ + name: TEMPLATE_NAME, + description: + "会读文档、验证 API、体验控制台并输出云知道深度测评的产品体验官。", + model: { id: modelId, effort: "high" }, + environment_id: environmentId, + system: `你是产品体验官。你的职责不是代写,而是先完成真实产品体验,再给出判断。 + +工作原则: +1. 结论必须绑定可复现证据;没有看到、没有调用、没有执行的内容不得写成实测。 +2. 默认只读。创建资源、提交表单、产生费用、修改配置或删除数据前必须请求确认。 +3. 同时使用新人视角与专家判断:记录首次理解成本,也判断架构、API 与治理能力。 +4. 每条问题使用“场景—操作—预期—实际—证据—影响—建议”结构。 +5. 交付中文 Markdown 深度测评,并附一段适合 IM 阅读的结论摘要。 +6. 如果当前 Template 未开放 BrowserUse,明确标记浏览器任务未执行,不得用 WebFetch 冒充真实交互。`, + tools: experienceOfficerTools("read-only", false), + skills: [], + metadata: { + app: "pxo-twin", + pack: "product-experience-officer", + safety: "read-only-by-default", + }, + }), + }, + ); + templateId = recordId(template, "tmpl_"); + provisioned = true; + } + + if (!templateId) throw new Error("QCA did not return a template id"); + + return { templateId, identityId, provisioned }; +} + +async function waitForEnvironmentReady( + config: ReturnType, + pat: string, + environment: QcaRecord, +) { + const environmentId = recordId(environment, "env_"); + if (!environmentId) throw new Error("QCA did not return an environment id"); + const initialStatus = + typeof environment.status === "string" ? environment.status : ""; + // Cloud environments are declarative resources. The public API currently + // omits a runtime status, which means they can be attached to a Session + // immediately and the platform provisions the container lazily. + if (!initialStatus || initialStatus === "ready") return environmentId; + + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1_500)); + const current = await qcaJson( + `${config.cloudBaseUrl}/environments/${environmentId}`, + pat, + ); + if (current.status === "ready") return environmentId; + if (current.status === "failed" || current.status === "archived") { + throw new Error(`QCA environment is ${String(current.status)}`); + } + } + throw new Error("QCA environment did not become ready within 30 seconds"); +} + +async function ensureManagedResources( + config: ReturnType, + pat: string, + mode: "read-only" | "authorized-e2e", +) { + const [environmentResponse, agentResponse] = await Promise.all([ + qcaJson(`${config.cloudBaseUrl}/environments?limit=100`, pat), + qcaJson(`${config.cloudBaseUrl}/agents?limit=100`, pat), + ]); + let provisioned = false; + let environment = dataRows(environmentResponse).find( + (item) => item.name === ENVIRONMENT_NAME, + ); + if (!environment) { + environment = await qcaJson(`${config.cloudBaseUrl}/environments`, pat, { + method: "POST", + body: JSON.stringify({ + name: ENVIRONMENT_NAME, + description: + "产品体验官的隔离执行环境;默认允许访问被测产品文档与公开 API。", + config: { + type: "cloud", + networking: { type: "unrestricted" }, + }, + metadata: { app: "pxo-twin", managed_by: "pxo-twin-site" }, + }), + }); + provisioned = true; + } + const environmentId = await waitForEnvironmentReady( + config, + pat, + environment, + ); + + const agentName = + mode === "read-only" ? READ_ONLY_AGENT_NAME : AUTHORIZED_AGENT_NAME; + let agent = dataRows(agentResponse).find((item) => item.name === agentName); + let agentId = recordId(agent, "agent_"); + if (!agentId) { + const models = await qcaJson(`${config.cloudBaseUrl}/models`, pat); + const model = dataRows(models).find((item) => item.id === "ultimate") + ?? dataRows(models)[0]; + const modelId = typeof model?.id === "string" ? model.id : ""; + if (!modelId) throw new Error("No enabled QCA model is available"); + agent = await qcaJson(`${config.cloudBaseUrl}/agents`, pat, { + method: "POST", + body: JSON.stringify({ + name: agentName, + description: + mode === "read-only" + ? "只读产品体验 Agent:禁止 Write/Bash 与被测产品副作用。" + : "授权端到端产品体验 Agent:只执行可逆、可清理的测试操作。", + model: { id: modelId, effort: "high" }, + system: agentSystem(mode), + tools: experienceOfficerTools(mode), + skills: [], + metadata: { + app: "pxo-twin", + pack: "product-experience-officer", + safety: mode, + policy_version: TEST_ACCESS_POLICY_VERSION, + }, + }), + }); + agentId = recordId(agent, "agent_"); + provisioned = true; + } else if ( + !hasBrowserUse(agent) || + (agent.metadata as QcaRecord | undefined)?.policy_version !== + TEST_ACCESS_POLICY_VERSION + ) { + const version = + typeof agent.version === "number" ? agent.version : 0; + if (!version) throw new Error("QCA Agent version is unavailable"); + agent = await qcaJson(`${config.cloudBaseUrl}/agents/${agentId}`, pat, { + method: "POST", + body: JSON.stringify({ + version, + system: agentSystem(mode), + tools: experienceOfficerTools(mode), + metadata: { + app: "pxo-twin", + pack: "product-experience-officer", + safety: mode, + policy_version: TEST_ACCESS_POLICY_VERSION, + }, + }), + }); + provisioned = true; + } + + if (!agentId) throw new Error("QCA did not return an agent id"); + if (!hasBrowserUse(agent)) { + throw new Error("QCA Managed Agent did not enable BrowserUse"); + } + return { agentId, environmentId, provisioned }; +} + +async function createEphemeralEnvironment( + config: ReturnType, + pat: string, + runId: string, +) { + const environment = await qcaJson(`${config.cloudBaseUrl}/environments`, pat, { + method: "POST", + body: JSON.stringify({ + name: `产品体验官 E2E · ${crypto.randomUUID().slice(0, 8)}`, + description: "产品体验官单次凭证化端到端评测环境;Session 结束后删除或归档。", + config: { + type: "cloud", + networking: { type: "unrestricted" }, + }, + metadata: { + app: "pxo-twin", + purpose: "credentialed-e2e", + ephemeral: "true", + run_id: runId, + policy_version: TEST_ACCESS_POLICY_VERSION, + }, + }), + }); + return waitForEnvironmentReady(config, pat, environment); +} + +async function createCredentialVault( + config: ReturnType, + pat: string, + access: ValidatedTestAccess, + runId: string, +) { + const vault = await qcaJson(`${config.cloudBaseUrl}/vaults`, pat, { + method: "POST", + body: JSON.stringify({ + display_name: `产品体验官 E2E · ${crypto.randomUUID().slice(0, 8)}`, + metadata: { + app: "pxo-twin", + purpose: "credentialed-e2e", + ephemeral: "true", + run_id: runId, + policy_version: TEST_ACCESS_POLICY_VERSION, + }, + }), + }); + const vaultId = recordId(vault, "vault_"); + if (!vaultId) throw new Error("QCA did not return a vault id"); + + try { + for (const secret of access.secrets) { + await qcaJson( + `${config.cloudBaseUrl}/vaults/${vaultId}/credentials`, + pat, + { + method: "POST", + body: JSON.stringify({ + auth: { + type: "environment_variable", + secret_name: secret.alias, + secret_value: secret.value, + }, + metadata: { + kind: secret.kind, + app: "pxo-twin", + ephemeral: "true", + }, + }), + }, + ); + } + } catch (error) { + await cleanupEphemeralResources(config, pat, { + vaultIds: [vaultId], + runId, + }).catch(() => "failed"); + throw error; + } + return vaultId; +} + +function safeRemoteId(value: unknown, prefix: string) { + return typeof value === "string" && + value.startsWith(prefix) && + /^[A-Za-z0-9_-]+$/.test(value) + ? value + : ""; +} + +function isProductExperienceEphemeral(record: QcaRecord) { + const metadata = + record.metadata && typeof record.metadata === "object" + ? (record.metadata as QcaRecord) + : {}; + return ( + metadata.app === "pxo-twin" && + metadata.purpose === "credentialed-e2e" && + (metadata.ephemeral === "true" || metadata.ephemeral === true) + ); +} + +function belongsToProductExperienceRun(record: QcaRecord, runId?: string) { + if (!isProductExperienceEphemeral(record)) return false; + if (!runId) return true; + const metadata = record.metadata as QcaRecord; + return metadata.run_id === runId; +} + +function isRemoteNotFound(error: unknown) { + return ( + error instanceof Error && + /(?:\b404\b|not found|does not exist)/i.test(error.message) + ); +} + +async function cleanupEphemeralResources( + config: ReturnType, + pat: string, + resources: { vaultIds?: string[]; environmentId?: string; runId?: string }, +): Promise { + const vaultIds = (resources.vaultIds || []) + .map((id) => safeRemoteId(id, "vault_")) + .filter(Boolean); + const environmentId = safeRemoteId(resources.environmentId, "env_"); + if (!vaultIds.length && !environmentId) return "not-required"; + + let failed = false; + for (const vaultId of vaultIds) { + try { + const detail = await qcaJson( + `${config.cloudBaseUrl}/vaults/${vaultId}`, + pat, + ); + if (!belongsToProductExperienceRun(detail, resources.runId)) { + failed = true; + continue; + } + const credentials = await qcaJson( + `${config.cloudBaseUrl}/vaults/${vaultId}/credentials?limit=100`, + pat, + ); + for (const credential of dataRows(credentials)) { + const credentialId = + typeof credential.id === "string" && + /^[A-Za-z0-9_-]+$/.test(credential.id) + ? credential.id + : ""; + if (!credentialId) continue; + await qcaJson( + `${config.cloudBaseUrl}/vaults/${vaultId}/credentials/${credentialId}`, + pat, + { method: "DELETE" }, + ); + } + await qcaJson(`${config.cloudBaseUrl}/vaults/${vaultId}`, pat, { + method: "DELETE", + }); + } catch (error) { + if (!isRemoteNotFound(error)) failed = true; + } + } + + if (environmentId) { + try { + const detail = await qcaJson( + `${config.cloudBaseUrl}/environments/${environmentId}`, + pat, + ); + if (!belongsToProductExperienceRun(detail, resources.runId)) { + failed = true; + } else { + try { + await qcaJson( + `${config.cloudBaseUrl}/environments/${environmentId}`, + pat, + { method: "DELETE" }, + ); + } catch { + await qcaJson( + `${config.cloudBaseUrl}/environments/${environmentId}/archive`, + pat, + { method: "POST", body: "{}" }, + ); + } + } + } catch (error) { + if (!isRemoteNotFound(error)) failed = true; + } + } + return failed ? "failed" : "complete"; +} + +function cleanupTargetsFromSession(session: QcaRecord) { + return { + vaultIds: Array.isArray(session.vault_ids) + ? session.vault_ids.filter( + (id): id is string => typeof id === "string", + ) + : [], + environmentId: + typeof session.environment_id === "string" + ? session.environment_id + : undefined, + }; +} + +async function discoverEphemeralResources( + config: ReturnType, + pat: string, + runId: string, +) { + const [vaults, environments] = await Promise.all([ + qcaJson(`${config.cloudBaseUrl}/vaults?limit=100`, pat), + qcaJson(`${config.cloudBaseUrl}/environments?limit=100`, pat), + ]); + const belongsToRun = (record: QcaRecord) => { + if (!isProductExperienceEphemeral(record)) return false; + const metadata = record.metadata as QcaRecord; + return metadata.run_id === runId; + }; + return { + vaultIds: dataRows(vaults) + .filter(belongsToRun) + .map((record) => safeRemoteId(record.id, "vault_")) + .filter(Boolean), + environmentId: + dataRows(environments) + .filter(belongsToRun) + .map((record) => safeRemoteId(record.id, "env_")) + .find(Boolean) || undefined, + }; +} + +function accessSummaryFromSession(session: QcaRecord) { + const metadata = + session.metadata && typeof session.metadata === "object" + ? (session.metadata as QcaRecord) + : {}; + return parseSafeTestAccessSummary(metadata.access_summary); +} + +function extractAgentMessages(events: QcaRecord) { + return dataRows(events) + .filter((event) => event.type === "agent.message") + .flatMap((event) => (Array.isArray(event.content) ? event.content : [])) + .filter( + (block): block is QcaRecord => + Boolean(block) && typeof block === "object", + ) + .map((block) => block.text) + .filter((text): text is string => typeof text === "string") + .map((text) => redactSensitiveText(text)); +} + +function extractDeliveredFileId(events: QcaRecord) { + for (const event of [...dataRows(events)].reverse()) { + if ( + event.type === "agent.artifact_delivered" && + typeof event.file_id === "string" + ) { + return safeRemoteId(event.file_id, "file_"); + } + } + return ""; +} + +async function fetchDeliveredReport( + fileId: string, + config: ReturnType, + pat: string, +) { + if (!fileId) return ""; + const content = await qcaJson( + `${config.cloudBaseUrl}/files/${fileId}/content`, + pat, + ); + const downloadUrl = typeof content.url === "string" ? content.url : ""; + if (!downloadUrl.startsWith("https://")) return ""; + const response = await fetch(downloadUrl); + return response.ok ? response.text() : ""; +} + +async function fetchDeliveredReportOrPending( + fileId: string, + load: (resolvedFileId: string) => Promise, +) { + if (!fileId) return ""; + try { + return await load(fileId); + } catch { + // Session/events access already succeeded in this poll. A file-specific + // 401/403/404 or transient download failure means the artifact is not + // readable yet, not that the PAT or Session is invalid. + return ""; + } +} + +function toolUseCorrelationId(event: QcaRecord) { + for (const candidate of [event.tool_use_id, event.id]) { + if (typeof candidate === "string" && candidate) return candidate; + } + return ""; +} + +function isAwaitingToolConfirmation( + sessionStatus: unknown, + rows: QcaRecord[], +) { + if (sessionStatus !== "idle") return false; + let currentTurnStart = -1; + rows.forEach((event, index) => { + if (event.type === "agent.message" || event.type === "user.message") { + currentTurnStart = index; + } + }); + const currentTurnRows = rows.slice(currentTurnStart + 1); + const resolvedToolUseIds = new Set( + currentTurnRows + .filter( + (event) => + event.type === "agent.tool_result" || + event.type === "user.tool_confirmation", + ) + .map(toolUseCorrelationId) + .filter(Boolean), + ); + return currentTurnRows + .filter((event) => event.type === "agent.tool_use") + .map(toolUseCorrelationId) + .filter(Boolean) + .some((toolUseId) => !resolvedToolUseIds.has(toolUseId)); +} + +function storedEvidence(serialized: string) { + try { + const parsed = JSON.parse(serialized); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as QcaRecord) + : {}; + } catch { + return {}; + } +} + +function eventMarkerIndex( + rows: QcaRecord[], + eventType: string, + marker: string, +) { + let markerIndex = -1; + rows.forEach((event, index) => { + const hasStandaloneMarker = contentText(event) + .split(/\r?\n/) + .some((line) => line.trim() === marker); + if (event.type === eventType && hasStandaloneMarker) { + markerIndex = index; + } + }); + return markerIndex; +} + +function latestAgentActivityId(rows: QcaRecord[]) { + const latest = [...rows] + .reverse() + .find( + (event) => + typeof event.type === "string" && + event.type.startsWith("agent."), + ); + if (!latest) return ""; + for (const candidate of [ + latest.id, + latest.event_id, + latest.sequence, + latest.created_at, + latest.timestamp, + ]) { + if ( + typeof candidate === "string" || + typeof candidate === "number" + ) { + return String(candidate).slice(0, 200); + } + } + return ""; +} + +function contentText(event: QcaRecord) { + if (!Array.isArray(event.content)) return ""; + return event.content + .filter( + (block): block is QcaRecord => + Boolean(block) && typeof block === "object", + ) + .map((block) => block.text) + .filter((text): text is string => typeof text === "string") + .join("\n"); +} + +function summarizeEvidence( + events: QcaRecord, + complete: boolean, + accessSummary?: TestAccessSummary | null, + cleanupStatus?: CleanupStatus, +) { + const rows = dataRows(events); + const toolUses = rows.filter((event) => event.type === "agent.tool_use"); + const toolNames = Array.from( + new Set( + toolUses + .map((event) => event.name || event.tool_name) + .filter((name): name is string => typeof name === "string"), + ), + ); + const messages = rows + .filter((event) => event.type === "agent.message") + .map((event) => redactSensitiveText(contentText(event))) + .filter(Boolean); + const errors = rows.filter( + (event) => + event.type === "session.error" || + (event.type === "agent.tool_result" && + /failed|error|not found|401|403|429/i.test(contentText(event))), + ).length; + const artifactId = extractDeliveredFileId(events); + const progress = complete + ? 100 + : Math.min( + 92, + 12 + Math.round(rows.length / 3) + toolUses.length * 4 + messages.length * 5, + ); + + return { + totalEvents: rows.length, + toolCalls: toolUses.length, + tools: toolNames, + messages: messages.length, + errors, + artifactDelivered: Boolean(artifactId), + recentNotes: messages + .filter((message) => !message.trimStart().startsWith("#")) + .slice(-4) + .map((message) => + message.length > 360 ? `${message.slice(0, 357)}…` : message, + ), + progress, + ...(accessSummary + ? { + access: { + ...accessSummary, + ...(cleanupStatus ? { cleanupStatus } : {}), + }, + } + : {}), + }; +} + +export async function GET() { + return responseJson({ + mode: "credential-required", + connected: false, + status: "ready", + credentialPolicy: "ephemeral", + }); +} + +export async function POST(request: Request) { + const owner = resolveRunOwner(request); + const respond = ( + data: Record, + init?: ResponseInit, + ) => withRunOwnerCookie(responseJson(data, init), owner); + const config = runtimeConfig(); + const body = (await request.json().catch(() => ({}))) as { + action?: string; + pat?: string; + product?: string; + depth?: string; + packId?: string; + productUrl?: string; + scopes?: string[]; + sessionId?: string; + sessionMode?: string; + runId?: string; + reconcile?: boolean; + channelType?: "dingtalk" | "feishu" | "wecom" | "wechat"; + testAccess?: unknown; + }; + + const supportedActions = new Set([ + "health", + "channels", + "poll", + "resume-login", + "cleanup", + "evaluate", + "connect-im", + ]); + if (!body.action || !supportedActions.has(body.action)) { + return respond( + { + mode: "error", + status: "error", + message: "Unsupported QCA action", + }, + { status: 400 }, + ); + } + + let pat = ""; + try { + pat = userPat(body.pat); + } catch (error) { + return respond( + { + mode: "credential-required", + connected: false, + status: "error", + message: + error instanceof Error ? error.message : "请输入有效的 Qoder PAT", + }, + { status: 401 }, + ); + } + + let validatedAccess: ValidatedTestAccess | null = null; + if (body.action === "evaluate") { + try { + validatedAccess = parseTestAccess(body.testAccess, body.productUrl); + if ( + typeof body.product !== "string" || + !body.product.trim() || + body.product.trim().length > 160 + ) { + throw new Error("被测产品名称必须为 1–160 个字符"); + } + if ( + !Array.isArray(body.scopes) || + body.scopes.length < 1 || + body.scopes.length > 10 || + body.scopes.some( + (scope) => typeof scope !== "string" || scope.length > 40, + ) + ) { + throw new Error("请至少选择一个有效评测范围"); + } + } catch (error) { + return respond( + { + mode: "live", + status: "error", + message: + error instanceof Error + ? redactSensitiveText(error.message, [pat]) + : "被测产品访问配置无效", + }, + { status: 400 }, + ); + } + } + + const targetSecrets = + validatedAccess?.secrets.map((secret) => secret.value) || []; + const safeMessage = (error: unknown, fallback: string) => + redactSensitiveText( + (error instanceof Error ? error.message : fallback).replaceAll( + pat, + "[redacted]", + ), + targetSecrets, + ); + + if (body.action === "health") { + try { + await qcaJson(`${config.cloudBaseUrl}/models`, pat); + return respond({ + mode: "live", + connected: true, + status: "ready", + delivery: "managed-evaluation-forward-im", + browserUse: "auto-enabled", + credentialPolicy: "ephemeral", + }); + } catch (error) { + return respond( + { + mode: "error", + connected: false, + status: "error", + message: safeMessage(error, "QCA connection failed"), + }, + { status: 502 }, + ); + } + } + + if (body.action === "channels") { + try { + const channels = await qcaJson( + `${config.forwardBaseUrl}/channels?limit=100`, + pat, + ); + const safeChannels = dataRows(channels).map((channel) => ({ + id: channel.id, + type: channel.channel_type, + name: channel.name, + enabled: channel.enabled, + bindingStatus: channel.binding_status, + })); + return respond({ mode: "live", channels: safeChannels }); + } catch (error) { + return respond( + { + mode: "live", + channels: [], + message: safeMessage(error, "Unable to load channels"), + }, + { status: 502 }, + ); + } + } + + if (body.action === "resume-login") { + const sessionId = body.sessionId || ""; + const runId = body.runId || ""; + if ( + !/^sess_[A-Za-z0-9_-]+$/.test(sessionId) || + !/^run_[a-f0-9]{32}$/.test(runId) + ) { + return respond( + { mode: "live", status: "error", message: "Invalid resume target" }, + { status: 400 }, + ); + } + try { + const run = await getEvaluation(owner.ownerId, runId); + if (!run || run.session_id !== sessionId) { + return respond( + { mode: "live", status: "error", message: "Resume target not found" }, + { status: 404 }, + ); + } + const [session, events] = await Promise.all([ + qcaJson(`${config.cloudBaseUrl}/sessions/${sessionId}`, pat), + qcaJson( + `${config.cloudBaseUrl}/sessions/${sessionId}/events?limit=100&order=desc`, + pat, + ), + ]); + const metadata = + session.metadata && typeof session.metadata === "object" + ? (session.metadata as QcaRecord) + : {}; + const summary = accessSummaryFromSession(session); + const chronologicalEvents = dataRows(events).reverse(); + const hasOpenLoginCheckpoint = + eventMarkerIndex( + chronologicalEvents, + "agent.message", + MANUAL_LOGIN_REQUIRED_MARKER, + ) > + eventMarkerIndex( + chronologicalEvents, + "user.message", + MANUAL_LOGIN_COMPLETED_MARKER, + ); + if ( + metadata.source !== "pxo-twin-web" || + summary?.mode !== "authorized-e2e" || + !summary.credentialCount || + !hasOpenLoginCheckpoint + ) { + return respond( + { + mode: "live", + status: "error", + message: "当前 Session 没有可恢复的产品体验官人工登录检查点", + }, + { status: 409 }, + ); + } + await qcaJson( + `${config.cloudBaseUrl}/sessions/${sessionId}/events`, + pat, + { + method: "POST", + body: JSON.stringify({ + events: [ + { + type: "user.message", + content: [ + { + type: "text", + text: `${MANUAL_LOGIN_COMPLETED_MARKER}\n用户已在 QCA 浏览器预览中人工完成登录。继续执行已授权评测;不得回读、输出或记录任何凭证。`, + }, + ], + }, + ], + }), + }, + ); + return respond({ mode: "live", status: "running" }); + } catch (error) { + return respond( + { + mode: "live", + status: "error", + message: safeMessage(error, "Unable to resume QCA session"), + }, + { status: 502 }, + ); + } + } + + if (body.action === "cleanup") { + const sessionId = body.sessionId || ""; + const runId = body.runId || ""; + if ( + (sessionId && !/^sess_[A-Za-z0-9_-]+$/.test(sessionId)) || + !/^run_[a-f0-9]{32}$/.test(runId) + ) { + return respond( + { mode: "live", status: "error", message: "Invalid cleanup target" }, + { status: 400 }, + ); + } + try { + const run = await getEvaluation(owner.ownerId, runId); + if (!run || (sessionId && run.session_id !== sessionId)) { + return respond( + { mode: "live", status: "error", message: "Cleanup target not found" }, + { status: 404 }, + ); + } + let previousEvidence: QcaRecord = {}; + try { + const parsed = JSON.parse(run.evidence_json); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + previousEvidence = parsed as QcaRecord; + } + } catch { + previousEvidence = {}; + } + let summary = accessSummaryFromSession({ + metadata: { access_summary: previousEvidence.access }, + }); + let sessionTargets: ReturnType = { + vaultIds: [], + }; + const effectiveSessionId = sessionId || run.session_id || ""; + if (effectiveSessionId) { + const session = await qcaJson( + `${config.cloudBaseUrl}/sessions/${effectiveSessionId}`, + pat, + ); + const metadata = + session.metadata && typeof session.metadata === "object" + ? (session.metadata as QcaRecord) + : {}; + if ( + metadata.source !== "pxo-twin-web" || + metadata.run_id !== runId + ) { + return respond( + { + mode: "live", + status: "error", + message: "清理目标不是产品体验官创建的 Session", + }, + { status: 409 }, + ); + } + summary = accessSummaryFromSession(session) || summary; + sessionTargets = cleanupTargetsFromSession(session); + } + const discovered = await discoverEphemeralResources(config, pat, runId); + const rawCleanupStatus = await cleanupEphemeralResources(config, pat, { + vaultIds: Array.from( + new Set([...(sessionTargets.vaultIds || []), ...discovered.vaultIds]), + ), + environmentId: + sessionTargets.environmentId || discovered.environmentId, + runId, + }); + const cleanupStatus = + rawCleanupStatus === "not-required" && summary?.credentialCount + ? "complete" + : rawCleanupStatus; + if (summary) { + await updateEvaluation(owner.ownerId, runId, { + evidence: { + ...previousEvidence, + access: { ...summary, cleanupStatus }, + }, + }).catch(() => false); + } + return respond({ mode: "live", status: cleanupStatus }); + } catch (error) { + return respond( + { + mode: "live", + status: "failed", + message: safeMessage(error, "QCA cleanup failed"), + }, + { status: 502 }, + ); + } + } + + if (body.action === "poll") { + const sessionId = body.sessionId || ""; + const sessionMode = body.sessionMode || "forward"; + const runId = body.runId || ""; + if ( + !/^sess_[A-Za-z0-9_-]+$/.test(sessionId) || + !/^run_[a-f0-9]{32}$/.test(runId) + ) { + return respond( + { mode: "live", status: "error", message: "Invalid polling target" }, + { status: 400 }, + ); + } + + const baseUrl = + sessionMode === "managed" ? config.cloudBaseUrl : config.forwardBaseUrl; + + try { + const run = await getEvaluation(owner.ownerId, runId); + if (!run || run.session_id !== sessionId) { + return respond( + { mode: "live", status: "error", message: "Polling target not found" }, + { status: 404 }, + ); + } + if (run.status === "complete" && body.reconcile !== true) { + const evidence = storedEvidence(run.evidence_json); + const access = accessSummaryFromSession({ + metadata: { access_summary: evidence.access }, + }); + return respond({ + mode: "live", + sessionMode, + status: "complete", + progress: 100, + report: run.report, + reportSource: + run.report_source === "qca-artifact" + ? "artifact" + : "messages", + evidence, + cleanupStatus: access?.cleanupStatus, + qcaConsoleUrl: qcaSessionConsoleUrl(sessionId), + }); + } + const [session, latestEvents] = await Promise.all([ + qcaJson(`${baseUrl}/sessions/${sessionId}`, pat), + qcaJson( + `${baseUrl}/sessions/${sessionId}/events?limit=100&order=desc`, + pat, + ), + ]); + // Long evaluations routinely exceed the 100-event page limit. Read the + // newest page so terminal status, the final agent message, and delivered + // artifacts cannot fall beyond an old ascending page, then restore + // chronological order for message/report assembly. + const events: QcaRecord = { + ...latestEvents, + data: dataRows(latestEvents).reverse(), + }; + const messages = extractAgentMessages(events); + const rows = dataRows(events); + const requiredLoginIndex = eventMarkerIndex( + rows, + "agent.message", + MANUAL_LOGIN_REQUIRED_MARKER, + ); + const completedLoginIndex = eventMarkerIndex( + rows, + "user.message", + MANUAL_LOGIN_COMPLETED_MARKER, + ); + const manualLoginRequired = + requiredLoginIndex > completedLoginIndex; + const hasSessionError = rows.some( + (event) => event.type === "session.error", + ); + const awaitingConfirmation = isAwaitingToolConfirmation( + session.status, + rows, + ); + const previousEvidence = storedEvidence(run.evidence_json); + const persistedAccessSummary = accessSummaryFromSession({ + metadata: { access_summary: previousEvidence.access }, + }); + const baseSummary = + accessSummaryFromSession(session) || persistedAccessSummary; + const accessSummary = baseSummary + ? { + ...baseSummary, + ...(persistedAccessSummary?.cleanupStatus + ? { + cleanupStatus: + persistedAccessSummary.cleanupStatus, + } + : {}), + loginStatus: manualLoginRequired + ? ("awaiting-user" as const) + : completedLoginIndex >= 0 && + completedLoginIndex > requiredLoginIndex + ? ("complete" as const) + : baseSummary.loginStatus, + } + : null; + const previousFinality = + previousEvidence.reportFinality && + typeof previousEvidence.reportFinality === "object" && + !Array.isArray(previousEvidence.reportFinality) + ? (previousEvidence.reportFinality as QcaRecord) + : {}; + const persistedContinuationCount = + typeof previousFinality.continuationCount === "number" + ? Math.max( + 0, + Math.min( + MAX_REPORT_CONTINUATIONS, + Math.floor(previousFinality.continuationCount), + ), + ) + : 0; + const currentAgentActivityId = latestAgentActivityId(rows); + const previousAgentActivityId = + typeof previousFinality.lastContinuationAgentEventId === "string" + ? previousFinality.lastContinuationAgentEventId + : ""; + const persistedLastContinuationAt = + typeof previousFinality.lastContinuationAt === "string" + ? previousFinality.lastContinuationAt + : ""; + const hasNewerAgentActivitySincePersistedAttempt = + Boolean(previousAgentActivityId) && + Boolean(currentAgentActivityId) && + previousAgentActivityId !== currentAgentActivityId; + const deliveredFileId = extractDeliveredFileId(events); + const deliveredReport = await fetchDeliveredReportOrPending( + deliveredFileId, + (fileId) => fetchDeliveredReport(fileId, config, pat), + ); + const artifactFetchPending = + Boolean(deliveredFileId) && !deliveredReport.trim(); + const artifactRetrieval = artifactRetrievalWindow({ + pending: artifactFetchPending, + persistedPendingSince: + typeof previousFinality.artifactPendingSince === "string" + ? previousFinality.artifactPendingSince + : undefined, + }); + const artifactPending = artifactRetrieval.pending; + const artifactRetrievalExhausted = artifactRetrieval.exhausted; + const legacyReconcileEligible = + body.reconcile === true && + run.status === "complete" && + run.report_source === "qca-messages" && + typeof previousFinality.qualifiedBy !== "string"; + const qualifiedReport = selectQualifiedReport({ + messages, + deliveredArtifact: deliveredReport, + allowLegacy: legacyReconcileEligible, + }); + const isComplete = + !hasSessionError && + !manualLoginRequired && + !awaitingConfirmation && + session.status === "idle" && + qualifiedReport.complete; + const continuationPolicy = reportContinuationDecision( + rows, + qualifiedReport.complete, + { + persistedAttempts: persistedContinuationCount, + hasNewerAgentActivitySincePersistedAttempt, + lastContinuationAt: persistedLastContinuationAt, + }, + ); + const completedCredentialedRunCannotResume = + run.status === "complete" && + Boolean(accessSummary?.credentialCount) && + !qualifiedReport.complete; + const hasAgentActivity = rows.some( + (event) => + typeof event.type === "string" && + event.type.startsWith("agent."), + ); + const canContinueReport = + session.status === "idle" && + hasAgentActivity && + !hasSessionError && + !manualLoginRequired && + !awaitingConfirmation && + !artifactFetchPending && + !qualifiedReport.complete && + (run.status !== "complete" || + (legacyReconcileEligible && + !completedCredentialedRunCannotResume)); + let continuationPosted = false; + let continuationAttempt = persistedContinuationCount; + let lastContinuationAgentEventId = previousAgentActivityId; + let lastContinuationAt = persistedLastContinuationAt; + if ( + canContinueReport && + continuationPolicy.shouldContinue && + continuationPolicy.nextAttempt + ) { + continuationAttempt = continuationPolicy.nextAttempt; + await qcaJson( + `${baseUrl}/sessions/${sessionId}/events`, + pat, + { + method: "POST", + headers: { + "idempotency-key": + `report-finality-${runId}-${sessionId}-${continuationAttempt}`, + }, + body: JSON.stringify({ + events: [ + { + type: "user.message", + content: [ + { + type: "text", + text: buildReportContinuationMessage( + continuationAttempt, + ), + }, + ], + }, + ], + }), + }, + ); + continuationPosted = true; + lastContinuationAgentEventId = currentAgentActivityId; + lastContinuationAt = new Date().toISOString(); + } + const reportExhausted = + run.status !== "complete" && + session.status === "idle" && + !hasSessionError && + !manualLoginRequired && + !awaitingConfirmation && + (!artifactFetchPending || artifactRetrievalExhausted) && + !qualifiedReport.complete && + (continuationPolicy.reason === "exhausted" || + artifactRetrievalExhausted); + const isTerminal = + isComplete || + reportExhausted || + (hasSessionError && run.status !== "complete"); + const rawCleanupStatus = + isTerminal && accessSummary?.credentialCount + ? await cleanupEphemeralResources( + config, + pat, + { ...cleanupTargetsFromSession(session), runId }, + ) + : accessSummary?.cleanupStatus; + const cleanupStatus = + rawCleanupStatus === "not-required" && + accessSummary?.credentialCount + ? "complete" + : rawCleanupStatus; + const report = redactSensitiveText(qualifiedReport.report); + const evidence = { + ...summarizeEvidence( + events, + isComplete, + accessSummary, + cleanupStatus, + ), + reportFinality: { + protocolVersion: 1, + continuationCount: Math.max( + continuationAttempt, + continuationPolicy.attemptsUsed, + ), + ...(lastContinuationAt ? { lastContinuationAt } : {}), + ...(lastContinuationAgentEventId + ? { lastContinuationAgentEventId } + : {}), + ...(qualifiedReport.source + ? { + qualifiedVersion: 1, + qualifiedBy: qualifiedReport.source, + } + : {}), + ...(artifactPending ? { artifactPending: true } : {}), + ...(artifactFetchPending + ? { artifactPendingSince: artifactRetrieval.pendingSince } + : {}), + }, + }; + const reportIncomplete = + session.status === "idle" && + !hasSessionError && + !manualLoginRequired && + !awaitingConfirmation && + !isComplete && + !artifactPending && + !continuationPosted && + (artifactRetrievalExhausted || + continuationPolicy.reason === "exhausted" || + (run.status === "complete" && + (!body.reconcile || + completedCredentialedRunCannotResume || + !legacyReconcileEligible))); + const responseStatus = hasSessionError + ? run.status === "complete" + ? "incomplete_report" + : "error" + : reportExhausted + ? "error" + : manualLoginRequired + ? "awaiting_login" + : awaitingConfirmation + ? "awaiting_confirmation" + : isComplete + ? "complete" + : artifactPending + ? "artifact_pending" + : continuationPosted || + continuationPolicy.reason === "waiting-for-agent" + ? "report_continuing" + : reportIncomplete + ? "incomplete_report" + : nonterminalSessionStatus(session.status); + + const persistPollState = () => { + if (run.status === "complete" && !isComplete) { + return updateEvaluation(owner.ownerId, runId, { evidence }); + } + return updateEvaluation(owner.ownerId, runId, { + status: hasSessionError + ? "failed" + : reportExhausted + ? "failed" + : isComplete + ? "complete" + : "running", + progress: evidence.progress, + report: isComplete ? report : undefined, + reportSource: isComplete + ? qualifiedReport.source === "artifact" + ? "qca-artifact" + : "qca-messages" + : undefined, + evidence, + errorMessage: hasSessionError + ? "QCA Session reported an error" + : artifactRetrievalExhausted + ? "QCA 报告原件连续读取失败" + : reportExhausted + ? "QCA 未在自动补全上限内交付完整报告" + : undefined, + }); + }; + + if (isTerminal) { + try { + await confirmEvaluationUpdate(persistPollState); + } catch { + return respond( + { + mode: "live", + sessionMode, + status: "sync_error", + retryable: true, + message: "评测已结束,但结果保存暂时失败,正在等待重新同步。", + qcaConsoleUrl: qcaSessionConsoleUrl(sessionId), + }, + { status: 503 }, + ); + } + } else { + await persistPollState().catch(() => false); + } + + const sessionAgent = + session.agent && typeof session.agent === "object" + ? (session.agent as QcaRecord) + : {}; + const agentId = + safeRemoteId(sessionAgent.id, "agent_") || + safeRemoteId(session.agent, "agent_") || + safeRemoteId(session.agent_id, "agent_"); + return respond({ + mode: "live", + sessionMode, + status: responseStatus, + progress: evidence.progress, + report: isComplete ? report : "", + reportSource: + qualifiedReport.source === "artifact" + ? "artifact" + : "messages", + evidence, + cleanupStatus, + qcaConsoleUrl: qcaSessionConsoleUrl(sessionId), + ...(manualLoginRequired && agentId + ? { qcaAgentUrl: `https://qoder.com/cloud/agents/${agentId}` } + : {}), + ...(hasSessionError + ? { + message: + run.status === "complete" + ? "QCA Session 当前存在异常,已保留原报告;请打开 Session 排查后再重新同步。" + : "QCA Session reported an error", + } + : reportExhausted + ? { + message: + artifactRetrievalExhausted + ? "QCA 报告原件连续读取失败,任务已停止并执行临时资源清理。请打开 Session 排查后新建评测。" + : "已达到自动补全上限,任务已停止并执行临时资源清理。请打开 QCA Session 排查后新建评测。", + } + : artifactPending + ? { + message: + "QCA 已交付报告原件,正在等待文件内容可读取。", + } + : continuationPosted + ? { + message: `QCA 已进入报告补全(${continuationAttempt}/${MAX_REPORT_CONTINUATIONS})。`, + } + : responseStatus === "report_continuing" + ? { + message: "QCA 正在补全完整报告,等待新的 Agent 输出。", + } + : responseStatus === "incomplete_report" + ? { + message: completedCredentialedRunCannotResume + ? "该历史任务使用的临时凭证已结束生命周期,无法安全自动续跑;请新建评测。" + : artifactRetrievalExhausted + ? "QCA 已交付报告原件,但内容连续读取失败;已保留原报告,请打开 Session 排查后再重新同步。" + : continuationPolicy.reason === "exhausted" + ? "已达到自动补全上限。请打开 QCA Session 完成报告后再重新同步。" + : "这份历史报告缺少可验证的完成信号,请使用“重新同步完整报告”。", + } + : {}), + }); + } catch (error) { + const failure = pollFailureContract(error); + return respond( + { + mode: failure.mode, + status: failure.status, + retryable: failure.retryable, + message: failure.message, + ...(failure.upstreamStatus + ? { upstreamStatus: failure.upstreamStatus } + : {}), + qcaConsoleUrl: qcaSessionConsoleUrl(sessionId), + }, + { status: failure.httpStatus }, + ); + } + } + + let runIdForFailure = ""; + let ephemeralEnvironmentId = ""; + let ephemeralVaultId = ""; + let sessionIdForFailure = ""; + let agentIdForFailure = ""; + try { + if (body.action === "connect-im") { + const resources = await ensureForwardResources(config, pat); + const channelType = body.channelType || "dingtalk"; + const channels = await qcaJson( + `${config.forwardBaseUrl}/channels?limit=100`, + pat, + ); + let channel = dataRows(channels).find( + (item) => + item.channel_type === channelType && + item.identity_id === resources.identityId && + item.template_id === resources.templateId, + ); + let channelId = recordId(channel, "channel_"); + + if (!channelId) { + channel = await qcaJson( + `${config.forwardBaseUrl}/channels`, + pat, + { + method: "POST", + body: JSON.stringify({ + identity_id: resources.identityId, + template_id: resources.templateId, + identity_resolution: { mode: "fixed" }, + channel_type: channelType, + name: `产品体验官 · ${channelType}`, + enabled: true, + channel_config: { + response_options: { + include_tool_calls: false, + include_thinking: false, + }, + }, + }), + }, + ); + channelId = recordId(channel, "channel_"); + } + + if (!channelId) throw new Error("QCA did not return a channel id"); + + if (channel?.binding_status === "bound") { + return respond({ + mode: "live", + status: "bound", + channelId, + channelType, + }); + } + + const qr = await qcaJson( + `${config.forwardBaseUrl}/channels/${channelId}/qr_sessions`, + pat, + { method: "POST", body: "{}" }, + ); + + return respond({ + mode: "live", + status: qr.status || "waiting", + channelId, + channelType, + qrCodeImage: + typeof qr.qr_code_image_base64 === "string" + ? qr.qr_code_image_base64 + : undefined, + qrCodeContent: + typeof qr.qr_code_content === "string" + ? qr.qr_code_content + : undefined, + expiresAt: + typeof qr.expires_at === "string" ? qr.expires_at : undefined, + }); + } + + if (!validatedAccess) throw new Error("被测产品访问配置无效"); + const product = body.product?.trim() || "Qoder Cloud Agents"; + const depth = body.depth || "标准深度"; + const packId = body.packId || "generic-aliyun-v1"; + const initialAccessSummary = toSafeTestAccessSummary(validatedAccess); + runIdForFailure = await createEvaluation(owner.ownerId, { + productName: product, + productUrl: validatedAccess.productUrl, + packId, + depth, + scopes: body.scopes || ["文档", "控制台", "API", "能力"], + status: "queued", + progress: 4, + }); + const resources = await ensureManagedResources( + config, + pat, + validatedAccess.mode, + ); + agentIdForFailure = resources.agentId; + const usesCredentialVault = + validatedAccess.mode === "authorized-e2e" && + validatedAccess.secrets.length > 0; + if (usesCredentialVault) { + ephemeralEnvironmentId = await createEphemeralEnvironment( + config, + pat, + runIdForFailure, + ); + ephemeralVaultId = await createCredentialVault( + config, + pat, + validatedAccess, + runIdForFailure, + ); + } + const environmentId = + ephemeralEnvironmentId || resources.environmentId; + const session = await qcaJson( + `${config.cloudBaseUrl}/sessions`, + pat, + { + method: "POST", + body: JSON.stringify({ + agent: resources.agentId, + environment_id: environmentId, + ...(ephemeralVaultId ? { vault_ids: [ephemeralVaultId] } : {}), + title: `产品体验官 · ${product}深度测评`, + metadata: buildQcaSessionMetadata({ + packId, + depth, + runId: runIdForFailure, + accessSummary: initialAccessSummary, + }), + }), + }, + ); + + const sessionId = recordId(session, "sess_"); + if (!sessionId) throw new Error("QCA did not return a session id"); + sessionIdForFailure = sessionId; + const sessionBindingPersisted = await updateEvaluation( + owner.ownerId, + runIdForFailure, + { + status: "running", + progress: 10, + sessionId, + sessionMode: "managed", + evidence: { + totalEvents: 0, + toolCalls: 0, + tools: [], + messages: 0, + errors: 0, + progress: 10, + access: initialAccessSummary, + }, + }, + ); + if (!sessionBindingPersisted) { + throw new Error("Unable to persist QCA Session binding"); + } + + await qcaJson( + `${config.cloudBaseUrl}/sessions/${sessionId}/events`, + pat, + { + method: "POST", + body: JSON.stringify({ + events: [ + { + type: "user.message", + content: [ + { + type: "text", + text: buildEvaluationPrompt({ + product, + productUrl: validatedAccess.productUrl, + depth, + packId, + access: validatedAccess, + }), + }, + ], + }, + ], + }), + }, + ); + + return respond({ + mode: "live", + status: "running", + sessionId, + sessionMode: "managed", + runId: runIdForFailure, + provisioned: resources.provisioned, + access: initialAccessSummary, + qcaConsoleUrl: qcaSessionConsoleUrl(sessionId), + }); + } catch (error) { + const message = safeMessage(error, "QCA run failed"); + const cleanupStatus = await cleanupEphemeralResources(config, pat, { + vaultIds: ephemeralVaultId ? [ephemeralVaultId] : [], + environmentId: ephemeralEnvironmentId || undefined, + runId: runIdForFailure || undefined, + }).catch(() => "failed" as const); + if (runIdForFailure) { + await updateEvaluation(owner.ownerId, runIdForFailure, { + status: "failed", + errorMessage: message, + evidence: validatedAccess + ? { + access: toSafeTestAccessSummary(validatedAccess, { + cleanupStatus, + }), + progress: 4, + } + : undefined, + }).catch(() => false); + } + return respond( + { + mode: "live", + status: "error", + message, + cleanupStatus, + ...(runIdForFailure ? { runId: runIdForFailure } : {}), + ...(sessionIdForFailure + ? { + sessionId: sessionIdForFailure, + sessionMode: "managed", + qcaConsoleUrl: qcaSessionConsoleUrl(sessionIdForFailure), + ...(agentIdForFailure + ? { + qcaAgentUrl: `https://qoder.com/cloud/agents/${agentIdForFailure}`, + } + : {}), + } + : {}), + }, + { status: 502 }, + ); + } +} + +// Legacy architecture marker for downstream source-contract checks: +// `templates/${templateId}` is intentionally not used by the evaluation path. +// Forward Template mutations remain isolated to explicit IM delivery setup; +// BrowserUse is configured directly on the Managed Agent. diff --git a/demos/product-experience-officer/app/api/runs/route.ts b/demos/product-experience-officer/app/api/runs/route.ts new file mode 100644 index 0000000..4042315 --- /dev/null +++ b/demos/product-experience-officer/app/api/runs/route.ts @@ -0,0 +1,126 @@ +import { + deleteEvaluations, + evaluationStats, + getEvaluation, + listEvaluations, +} from "@/db/evaluations"; +import { + resolveRunOwner, + type RunOwner, + withRunOwnerCookie, +} from "@/lib/run-owner"; + +function parseJson(value: unknown, fallback: unknown) { + if (typeof value !== "string") return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function serializeRun(run: Record, includeReport = false) { + return { + id: run.id, + productName: run.product_name, + productUrl: run.product_url, + packId: run.pack_id, + depth: run.depth, + scopes: parseJson(run.scopes_json, []), + status: run.status, + progress: run.progress, + sessionId: run.session_id, + sessionMode: run.session_mode, + reportSource: run.report_source, + evidence: parseJson(run.evidence_json, {}), + errorMessage: run.error_message, + createdAt: run.created_at, + updatedAt: run.updated_at, + completedAt: run.completed_at, + hasReport: + run.has_report === true || + run.has_report === 1 || + (typeof run.report === "string" && run.report.length > 0), + ...(includeReport ? { report: run.report || "" } : {}), + }; +} + +function ownerJson( + owner: RunOwner, + data: Record, + init?: ResponseInit, +) { + const headers = new Headers(init?.headers); + headers.set("cache-control", "no-store"); + headers.set("content-type", "application/json"); + return withRunOwnerCookie( + new Response(JSON.stringify(data), { ...init, headers }), + owner, + ); +} + +export async function GET(request: Request) { + const owner = resolveRunOwner(request); + try { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + if (id) { + if (!/^run_[a-f0-9]{32}$/.test(id)) { + return ownerJson(owner, { error: "Invalid run id" }, { status: 400 }); + } + const run = await getEvaluation(owner.ownerId, id); + if (!run) { + return ownerJson(owner, { error: "Run not found" }, { status: 404 }); + } + return ownerJson(owner, { + run: serializeRun( + run as unknown as Record, + true, + ), + }); + } + + const [runs, stats] = await Promise.all([ + listEvaluations( + owner.ownerId, + Number(url.searchParams.get("limit") || 30), + ), + evaluationStats(owner.ownerId), + ]); + return ownerJson(owner, { + runs: runs.map((run) => + serializeRun(run as Record, false), + ), + stats, + }); + } catch (error) { + return ownerJson( + owner, + { + error: + error instanceof Error ? error.message : "Unable to load run history", + }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: Request) { + const owner = resolveRunOwner(request); + try { + const deleted = await deleteEvaluations(owner.ownerId); + const stats = await evaluationStats(owner.ownerId); + return ownerJson(owner, { deleted, stats }); + } catch (error) { + return ownerJson( + owner, + { + error: + error instanceof Error + ? error.message + : "Unable to clear run history", + }, + { status: 500 }, + ); + } +} diff --git a/demos/product-experience-officer/app/report-markdown.tsx b/demos/product-experience-officer/app/report-markdown.tsx new file mode 100644 index 0000000..030487b --- /dev/null +++ b/demos/product-experience-officer/app/report-markdown.tsx @@ -0,0 +1,380 @@ +import { + isValidElement, + useId, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; +import ReactMarkdown, { + defaultUrlTransform, + type Components, + type UrlTransform, +} from "react-markdown"; +import remarkGfm from "remark-gfm"; + +type ReportMarkdownProps = { + source: string; +}; + +type ReportHastNode = { + children?: ReportHastNode[]; + properties?: Record; + tagName?: string; + type?: string; + value?: string; +}; + +const blockedProtocolPattern = /^(?:javascript|data|vbscript):/i; +const headingIdPrefix = "report-heading-"; +const mermaidNodePattern = '[A-Za-z][A-Za-z0-9_]{0,31}\\["[^"\\r\\n]+"\\]'; +const mermaidChainPattern = new RegExp( + `^${mermaidNodePattern}(?:\\s*-->\\s*${mermaidNodePattern}){2,5}$`, +); +const mermaidNodeCapturePattern = + /([A-Za-z][A-Za-z0-9_]{0,31})\["([^"\r\n]+)"\]/g; +const unsafeDiagramLabelPattern = + /[\u0000-\u001f\u007f<>]|(?:https?:\/\/|\/\/|www\.|mailto:|javascript:|data:)/iu; +const maxDiagramLabelLength = 32; + +type ReportDiagramNode = { + id: string; + label: string; +}; + +function safeReportDestination(url: string) { + const transformed = defaultUrlTransform(url); + const normalizedProtocol = transformed + .slice(0, transformed.indexOf(":") + 1) + .replace(/[\u0000-\u0020]/g, ""); + + if (blockedProtocolPattern.test(normalizedProtocol)) return ""; + return transformed; +} + +export const safeReportUrl: UrlTransform = (url) => + safeReportDestination(url); + +function isExternalDestination(href: string) { + return /^(?:https?:|mailto:|\/\/)/i.test(href); +} + +function visitElements( + node: ReportHastNode, + visitor: (element: ReportHastNode) => void, +) { + if (node.type === "element") visitor(node); + for (const child of node.children ?? []) visitElements(child, visitor); +} + +function nodeText(node: ReportHastNode): string { + if (node.type === "text") return node.value ?? ""; + if (node.tagName === "img" && typeof node.properties?.alt === "string") { + return node.properties.alt; + } + return (node.children ?? []).map(nodeText).join(""); +} + +function conservativeHeadingSlug(value: string) { + const slug = value + .normalize("NFKC") + .toLowerCase() + .replace(/[^\p{Letter}\p{Number}]+/gu, "-") + .replace(/^-+|-+$/g, ""); + return Array.from(slug || "section").slice(0, 96).join(""); +} + +function decodeFragment(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function reportHeadingFragments() { + return (tree: ReportHastNode) => { + const duplicateCounts = new Map(); + const firstTargetBySlug = new Map(); + const headingTagPattern = /^h[1-6]$/; + const usedIds = new Set(); + + visitElements(tree, (element) => { + if (!element.tagName || !headingTagPattern.test(element.tagName)) return; + + const slug = conservativeHeadingSlug(nodeText(element)); + const baseId = `${headingIdPrefix}${slug}`; + let occurrence = (duplicateCounts.get(baseId) ?? 0) + 1; + let id = occurrence === 1 ? baseId : `${baseId}-${occurrence}`; + + while (usedIds.has(id)) { + occurrence += 1; + id = `${baseId}-${occurrence}`; + } + + duplicateCounts.set(baseId, occurrence); + usedIds.add(id); + if (!firstTargetBySlug.has(slug)) firstTargetBySlug.set(slug, id); + element.properties = { ...element.properties, id }; + }); + + visitElements(tree, (element) => { + const href = element.properties?.href; + if (element.tagName !== "a" || typeof href !== "string") return; + if (!href.startsWith("#")) return; + + const decoded = decodeFragment(href.slice(1)); + const slug = conservativeHeadingSlug(decoded); + const target = firstTargetBySlug.get(slug) ?? `${headingIdPrefix}${slug}`; + element.properties = { ...element.properties, href: `#${target}` }; + }); + }; +} + +function visibleReactText(value: ReactNode): string { + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + if (Array.isArray(value)) return value.map(visibleReactText).join(""); + if (isValidElement<{ children?: ReactNode }>(value)) { + return visibleReactText(value.props.children); + } + return ""; +} + +function headingPriority(value: ReactNode) { + const match = visibleReactText(value).match( + /(?:^|[^\p{Letter}\p{Number}])P([0-3])(?=$|[^\p{Letter}\p{Number}])/iu, + ); + return match?.[1]; +} + +function ShiftedHeading({ + level, + children, + className, + ...props +}: ComponentPropsWithoutRef<"h2"> & { + level: 2 | 3 | 4 | 5 | 6; +}) { + const Heading = `h${level}` as const; + const priority = headingPriority(children); + const priorityClass = priority + ? `report-priority-heading report-priority-p${priority}` + : ""; + const headingClassName = [className, priorityClass].filter(Boolean).join(" "); + + return ( + + {children} + + ); +} + +function SafeLink({ + href, + children, + ...props +}: ComponentPropsWithoutRef<"a">) { + const safeHref = href ? safeReportDestination(href) : ""; + + if (!safeHref) { + return {children}; + } + + const external = isExternalDestination(safeHref); + return ( + + {children} + + ); +} + +function SafeImagePlaceholder({ + alt, + title, +}: ComponentPropsWithoutRef<"img">) { + const label = alt || title || "报告图片"; + return ( + + 图片已隐藏:{label} + + ); +} + +function ScrollableTable({ + children, + ...props +}: ComponentPropsWithoutRef<"table">) { + return ( +
+ {children}
+
+ ); +} + +function parseReportDiagram(source: string): ReportDiagramNode[] | null { + if (source.length > 1_024) return null; + + const lines = source.replace(/\r\n?/g, "\n").trim().split("\n"); + if (lines.length !== 2 || lines[0].trim() !== "flowchart LR") return null; + + const chain = lines[1].trim(); + if (!mermaidChainPattern.test(chain)) return null; + + const nodes = Array.from(chain.matchAll(mermaidNodeCapturePattern), (match) => ({ + id: match[1], + label: match[2], + })); + if (nodes.length < 3 || nodes.length > 6) return null; + if (new Set(nodes.map((node) => node.id)).size !== nodes.length) return null; + + for (const node of nodes) { + const labelLength = Array.from(node.label).length; + if ( + node.label.trim() !== node.label || + labelLength === 0 || + labelLength > maxDiagramLabelLength || + unsafeDiagramLabelPattern.test(node.label) + ) { + return null; + } + } + + return nodes; +} + +function splitDiagramLabel(label: string) { + const characters = Array.from(label); + if (characters.length <= 14) return [label]; + const midpoint = Math.ceil(characters.length / 2); + return [ + characters.slice(0, midpoint).join(""), + characters.slice(midpoint).join(""), + ]; +} + +function ReportFlowchart({ nodes }: { nodes: ReportDiagramNode[] }) { + const accessibleId = useId(); + const titleId = `${accessibleId}-title`; + const descriptionId = `${accessibleId}-description`; + const nodeWidth = 176; + const nodeGap = 64; + const inset = 24; + const nodeHeight = 64; + const nodeY = 25; + const width = inset * 2 + nodes.length * nodeWidth + (nodes.length - 1) * nodeGap; + + return ( +
+ + 报告流程图 + + {nodes.map((node) => node.label).join(",依次流向:")} + + {nodes.slice(0, -1).map((node, index) => { + const startX = inset + (index + 1) * nodeWidth + index * nodeGap; + const endX = startX + nodeGap; + const centerY = nodeY + nodeHeight / 2; + return ( + + ); + })} + {nodes.map((node, index) => { + const x = inset + index * (nodeWidth + nodeGap); + const labelLines = splitDiagramLabel(node.label); + return ( + + + + {labelLines.map((line, lineIndex) => ( + + {line} + + ))} + + + ); + })} + +
+ ); +} + +function DiagramAwarePre({ + children, + ...props +}: ComponentPropsWithoutRef<"pre">) { + if ( + isValidElement<{ className?: string; children?: ReactNode }>(children) && + children.props.className === "language-mermaid" && + typeof children.props.children === "string" + ) { + const diagram = parseReportDiagram(children.props.children); + if (diagram) return ; + } + + return
{children}
; +} + +function withoutMarkdownNode({ + node, + ...props +}: T): Omit { + void node; + return props; +} + +const reportComponents: Components = { + h1: (props) => , + h2: (props) => , + h3: (props) => , + h4: (props) => , + h5: (props) => , + h6: (props) => , + a: (props) => , + img: (props) => , + pre: (props) => , + table: (props) => , +}; + +export function ReportMarkdown({ source }: ReportMarkdownProps) { + return ( + + {source} + + ); +} diff --git a/demos/product-experience-officer/db/evaluations.ts b/demos/product-experience-officer/db/evaluations.ts new file mode 100644 index 0000000..0798ed1 --- /dev/null +++ b/demos/product-experience-officer/db/evaluations.ts @@ -0,0 +1,518 @@ +import { getSupabaseAdmin } from "@/db/supabase-admin"; + +export type EvaluationStatus = + | "queued" + | "running" + | "complete" + | "failed"; + +export type EvaluationRecord = { + id: string; + owner_id: string; + product_name: string; + product_url: string; + pack_id: string; + depth: string; + scopes_json: string; + status: EvaluationStatus; + progress: number; + session_id: string | null; + session_mode: string | null; + report: string; + report_source: string; + evidence_json: string; + error_message: string; + created_at: string; + updated_at: string; + completed_at: string | null; +}; + +export type EvaluationPatch = { + status?: EvaluationStatus; + progress?: number; + sessionId?: string; + sessionMode?: string; + report?: string; + reportSource?: string; + evidence?: Record; + errorMessage?: string; +}; + +const CREATE_EVALUATIONS = `CREATE TABLE IF NOT EXISTS evaluations ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT '', + product_name TEXT NOT NULL, + product_url TEXT NOT NULL DEFAULT '', + pack_id TEXT NOT NULL, + depth TEXT NOT NULL, + scopes_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + session_id TEXT, + session_mode TEXT, + report TEXT NOT NULL DEFAULT '', + report_source TEXT NOT NULL DEFAULT '', + evidence_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT +)`; + +const ADD_OWNER_ID = + "ALTER TABLE evaluations ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''"; +const CREATE_OWNER_CREATED_INDEX = + "CREATE INDEX IF NOT EXISTS evaluations_owner_created_at_idx ON evaluations(owner_id, created_at DESC)"; +const CREATE_OWNER_STATUS_INDEX = + "CREATE INDEX IF NOT EXISTS evaluations_owner_status_idx ON evaluations(owner_id, status)"; +const DROP_UNSCOPED_CREATED_INDEX = + "DROP INDEX IF EXISTS evaluations_created_at_idx"; +const DROP_UNSCOPED_STATUS_INDEX = + "DROP INDEX IF EXISTS evaluations_status_idx"; + +const isMeooImageRuntime = process.env.MEOO_RUNTIME === "image"; +let schemaPromise: Promise | null = null; + +async function binding() { + const { env } = await import("cloudflare:workers"); + if (!env.DB) throw new Error("D1 binding DB is unavailable"); + return env.DB; +} + +async function initializeD1Schema() { + const db = await binding(); + await db.prepare(CREATE_EVALUATIONS).run(); + const columns = await db + .prepare("PRAGMA table_info(evaluations)") + .all<{ name: string }>(); + if (!columns.results.some((column) => column.name === "owner_id")) { + await db.prepare(ADD_OWNER_ID).run(); + } + await db.batch([ + db.prepare(DROP_UNSCOPED_CREATED_INDEX), + db.prepare(DROP_UNSCOPED_STATUS_INDEX), + db.prepare(CREATE_OWNER_CREATED_INDEX), + db.prepare(CREATE_OWNER_STATUS_INDEX), + ]); +} + +export async function ensureEvaluationsSchema() { + if (isMeooImageRuntime) return; + if (!schemaPromise) { + schemaPromise = initializeD1Schema().catch((error) => { + schemaPromise = null; + throw error; + }); + } + await schemaPromise; +} + +function supabaseFailure(operation: string, message: string) { + return new Error(`Unable to ${operation} evaluation history: ${message}`); +} + +type EvaluationUpdateValues = Partial< + Pick< + EvaluationRecord, + | "status" + | "progress" + | "session_id" + | "session_mode" + | "report" + | "report_source" + | "evidence_json" + | "error_message" + | "updated_at" + | "completed_at" + > +>; + +export type EvaluationUpdateAttempt = { + values: EvaluationUpdateValues; + allowedCurrentStatuses?: readonly EvaluationStatus[]; +}; + +const ALL_EVALUATION_STATUSES = [ + "queued", + "running", + "complete", + "failed", +] as const; +const NONTERMINAL_EVALUATION_STATUSES = ["queued", "running"] as const; + +function evaluationUpdateValues( + patch: EvaluationPatch, + now: string, + setCompletedAt: boolean, +) { + const values: EvaluationUpdateValues = { updated_at: now }; + if (patch.status !== undefined) values.status = patch.status; + if (patch.progress !== undefined) values.progress = patch.progress; + if (patch.sessionId !== undefined) values.session_id = patch.sessionId; + if (patch.sessionMode !== undefined) { + values.session_mode = patch.sessionMode; + } + if (patch.report !== undefined) values.report = patch.report; + if (patch.reportSource !== undefined) { + values.report_source = patch.reportSource; + } + if (patch.evidence !== undefined) { + values.evidence_json = JSON.stringify(patch.evidence); + } + if (patch.errorMessage !== undefined) { + values.error_message = patch.errorMessage; + } + if (patch.status === "complete" && setCompletedAt) { + values.completed_at = now; + } + return values; +} + +export function buildEvaluationUpdatePlan( + patch: EvaluationPatch, + now: string, +): EvaluationUpdateAttempt[] { + // queued → running → complete|failed. Terminal rows are absorbing, while + // a same-terminal retry remains legal so callers can finish persisting data. + if (patch.status === undefined) { + return [ + { + values: evaluationUpdateValues(patch, now, false), + allowedCurrentStatuses: ALL_EVALUATION_STATUSES, + }, + ]; + } + if (patch.status === "queued") { + return [ + { + values: evaluationUpdateValues(patch, now, false), + allowedCurrentStatuses: ["queued"], + }, + ]; + } + if (patch.status === "running") { + return [ + { + values: evaluationUpdateValues(patch, now, false), + allowedCurrentStatuses: NONTERMINAL_EVALUATION_STATUSES, + }, + ]; + } + + return [ + { + values: evaluationUpdateValues(patch, now, true), + allowedCurrentStatuses: NONTERMINAL_EVALUATION_STATUSES, + }, + { + values: evaluationUpdateValues(patch, now, false), + allowedCurrentStatuses: [patch.status], + }, + ]; +} + +export async function executeEvaluationUpdatePlan( + plan: readonly EvaluationUpdateAttempt[], + executeAttempt: (attempt: EvaluationUpdateAttempt) => Promise, +) { + for (const attempt of plan) { + if (await executeAttempt(attempt)) return true; + } + return false; +} + +export async function executeSupabaseEvaluationUpdateAttempt( + client: ReturnType, + ownerId: string, + id: string, + attempt: EvaluationUpdateAttempt, +) { + const query = client + .from("evaluations") + .update(attempt.values) + .eq("owner_id", ownerId) + .eq("id", id); + const allowedStatuses = attempt.allowedCurrentStatuses; + const filteredQuery = + allowedStatuses?.length === 1 + ? query.eq("status", allowedStatuses[0]) + : allowedStatuses + ? query.in("status", [...allowedStatuses]) + : query; + const { data, error } = await filteredQuery.select("id").maybeSingle(); + if (error) throw supabaseFailure("update", error.message); + return Boolean(data); +} + +const D1_UPDATE_COLUMNS = [ + ["status", "status"], + ["progress", "progress"], + ["session_id", "session_id"], + ["session_mode", "session_mode"], + ["report", "report"], + ["report_source", "report_source"], + ["evidence_json", "evidence_json"], + ["error_message", "error_message"], + ["updated_at", "updated_at"], + ["completed_at", "completed_at"], +] as const; + +type D1UpdateBinding = string | number | null; + +export function buildD1EvaluationUpdateStatement( + ownerId: string, + id: string, + attempt: EvaluationUpdateAttempt, +) { + const assignments: string[] = []; + const bindings: D1UpdateBinding[] = []; + for (const [key, column] of D1_UPDATE_COLUMNS) { + if (!Object.prototype.hasOwnProperty.call(attempt.values, key)) continue; + assignments.push(`${column} = ?`); + bindings.push(attempt.values[key] ?? null); + } + let sql = `UPDATE evaluations SET ${assignments.join(", ")} + WHERE owner_id = ? AND id = ?`; + bindings.push(ownerId, id); + const allowedStatuses = attempt.allowedCurrentStatuses; + if (allowedStatuses?.length) { + sql += ` AND status IN (${allowedStatuses.map(() => "?").join(", ")})`; + bindings.push(...allowedStatuses); + } + return { sql, bindings }; +} + +export async function executeD1EvaluationUpdateAttempt( + db: Awaited>, + ownerId: string, + id: string, + attempt: EvaluationUpdateAttempt, +) { + const statement = buildD1EvaluationUpdateStatement( + ownerId, + id, + attempt, + ); + const result = await db + .prepare(statement.sql) + .bind(...statement.bindings) + .run(); + return Number(result.meta.changes || 0) > 0; +} + +export async function createEvaluation( + ownerId: string, + input: { + productName: string; + productUrl?: string; + packId: string; + depth: string; + scopes: string[]; + status?: EvaluationStatus; + progress?: number; + report?: string; + reportSource?: string; + evidence?: Record; + }, +) { + const now = new Date().toISOString(); + const id = `run_${crypto.randomUUID().replaceAll("-", "")}`; + const record: EvaluationRecord = { + id, + owner_id: ownerId, + product_name: input.productName, + product_url: input.productUrl || "", + pack_id: input.packId, + depth: input.depth, + scopes_json: JSON.stringify(input.scopes), + status: input.status || "queued", + progress: input.progress || 0, + session_id: null, + session_mode: null, + report: input.report || "", + report_source: input.reportSource || "", + evidence_json: JSON.stringify(input.evidence || {}), + error_message: "", + created_at: now, + updated_at: now, + completed_at: input.status === "complete" ? now : null, + }; + + if (isMeooImageRuntime) { + const { error } = await getSupabaseAdmin() + .from("evaluations") + .insert(record); + if (error) throw supabaseFailure("create", error.message); + return id; + } + + await ensureEvaluationsSchema(); + const db = await binding(); + await db + .prepare( + `INSERT INTO evaluations ( + id, owner_id, product_name, product_url, pack_id, depth, scopes_json, + status, progress, session_id, session_mode, report, report_source, + evidence_json, error_message, created_at, updated_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + record.id, + record.owner_id, + record.product_name, + record.product_url, + record.pack_id, + record.depth, + record.scopes_json, + record.status, + record.progress, + record.session_id, + record.session_mode, + record.report, + record.report_source, + record.evidence_json, + record.error_message, + record.created_at, + record.updated_at, + record.completed_at, + ) + .run(); + return id; +} + +export async function updateEvaluation( + ownerId: string, + id: string, + patch: EvaluationPatch, +): Promise { + // False means missing row, owner mismatch, or a rejected state transition. + const plan = buildEvaluationUpdatePlan(patch, new Date().toISOString()); + + if (isMeooImageRuntime) { + const client = getSupabaseAdmin(); + return executeEvaluationUpdatePlan(plan, (attempt) => + executeSupabaseEvaluationUpdateAttempt( + client, + ownerId, + id, + attempt, + ), + ); + } + + await ensureEvaluationsSchema(); + const db = await binding(); + return executeEvaluationUpdatePlan(plan, (attempt) => + executeD1EvaluationUpdateAttempt(db, ownerId, id, attempt), + ); +} + +export async function getEvaluation(ownerId: string, id: string) { + if (isMeooImageRuntime) { + const { data, error } = await getSupabaseAdmin() + .from("evaluations") + .select("*") + .eq("owner_id", ownerId) + .eq("id", id) + .maybeSingle(); + if (error) throw supabaseFailure("read", error.message); + return data as EvaluationRecord | null; + } + + await ensureEvaluationsSchema(); + return (await binding()) + .prepare( + "SELECT * FROM evaluations WHERE owner_id = ? AND id = ? LIMIT 1", + ) + .bind(ownerId, id) + .first(); +} + +export async function listEvaluations(ownerId: string, limit = 30) { + const requestedLimit = Number.isFinite(limit) ? Math.trunc(limit) : 30; + const safeLimit = Math.min(Math.max(requestedLimit, 1), 100); + if (isMeooImageRuntime) { + const { data, error } = await getSupabaseAdmin() + .from("evaluations") + .select( + "id, product_name, product_url, pack_id, depth, scopes_json, status, progress, session_id, session_mode, report_source, evidence_json, error_message, created_at, updated_at, completed_at, has_report", + ) + .eq("owner_id", ownerId) + .order("created_at", { ascending: false }) + .limit(safeLimit); + if (error) throw supabaseFailure("list", error.message); + return data || []; + } + + await ensureEvaluationsSchema(); + const result = await (await binding()) + .prepare( + `SELECT + id, product_name, product_url, pack_id, depth, scopes_json, + status, progress, session_id, session_mode, report_source, + evidence_json, error_message, created_at, updated_at, completed_at, + CASE WHEN LENGTH(report) > 0 THEN 1 ELSE 0 END AS has_report + FROM evaluations + WHERE owner_id = ? + ORDER BY created_at DESC + LIMIT ?`, + ) + .bind(ownerId, safeLimit) + .all(); + return result.results; +} + +export async function evaluationStats(ownerId: string) { + if (isMeooImageRuntime) { + const { data, error } = await getSupabaseAdmin().rpc( + "evaluation_stats_for_owner", + { p_owner_id: ownerId }, + ); + if (error) throw supabaseFailure("summarize", error.message); + const row = data?.[0]; + return { + total: Number(row?.total || 0), + complete: Number(row?.complete || 0), + running: Number(row?.running || 0), + failed: Number(row?.failed || 0), + }; + } + + await ensureEvaluationsSchema(); + const row = await (await binding()) + .prepare( + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END) AS complete, + SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed + FROM evaluations + WHERE owner_id = ?`, + ) + .bind(ownerId) + .first>(); + return { + total: Number(row?.total || 0), + complete: Number(row?.complete || 0), + running: Number(row?.running || 0), + failed: Number(row?.failed || 0), + }; +} + +export async function deleteEvaluations(ownerId: string) { + if (isMeooImageRuntime) { + const { count, error } = await getSupabaseAdmin() + .from("evaluations") + .delete({ count: "exact" }) + .eq("owner_id", ownerId); + if (error) throw supabaseFailure("delete", error.message); + return Number(count || 0); + } + + await ensureEvaluationsSchema(); + const result = await (await binding()) + .prepare("DELETE FROM evaluations WHERE owner_id = ?") + .bind(ownerId) + .run(); + return Number(result.meta.changes || 0); +} diff --git a/demos/product-experience-officer/db/index.ts b/demos/product-experience-officer/db/index.ts new file mode 100644 index 0000000..19b3799 --- /dev/null +++ b/demos/product-experience-officer/db/index.ts @@ -0,0 +1,13 @@ +import { env } from "cloudflare:workers"; +import { drizzle } from "drizzle-orm/d1"; +import * as schema from "./schema"; + +export function getDb() { + if (!env.DB) { + throw new Error( + "Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database." + ); + } + + return drizzle(env.DB, { schema }); +} diff --git a/demos/product-experience-officer/db/schema.ts b/demos/product-experience-officer/db/schema.ts new file mode 100644 index 0000000..5797187 --- /dev/null +++ b/demos/product-experience-officer/db/schema.ts @@ -0,0 +1,32 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const evaluations = sqliteTable( + "evaluations", + { + id: text("id").primaryKey(), + ownerId: text("owner_id").notNull().default(""), + productName: text("product_name").notNull(), + productUrl: text("product_url").notNull().default(""), + packId: text("pack_id").notNull(), + depth: text("depth").notNull(), + scopesJson: text("scopes_json").notNull().default("[]"), + status: text("status").notNull(), + progress: integer("progress").notNull().default(0), + sessionId: text("session_id"), + sessionMode: text("session_mode"), + report: text("report").notNull().default(""), + reportSource: text("report_source").notNull().default(""), + evidenceJson: text("evidence_json").notNull().default("{}"), + errorMessage: text("error_message").notNull().default(""), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + completedAt: text("completed_at"), + }, + (table) => [ + index("evaluations_owner_created_at_idx").on( + table.ownerId, + table.createdAt, + ), + index("evaluations_owner_status_idx").on(table.ownerId, table.status), + ], +); diff --git a/demos/product-experience-officer/db/supabase-admin.ts b/demos/product-experience-officer/db/supabase-admin.ts new file mode 100644 index 0000000..cf2d09b --- /dev/null +++ b/demos/product-experience-officer/db/supabase-admin.ts @@ -0,0 +1,28 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "@/src/supabase/types"; + +let adminClient: SupabaseClient | null = null; + +export function getSupabaseAdmin() { + if (typeof window !== "undefined") { + throw new Error("Supabase admin access is server-only"); + } + if (adminClient) return adminClient; + + const url = process.env.SUPABASE_URL?.trim(); + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!url || !serviceRoleKey) { + throw new Error( + "Meoo persistence is unavailable: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required", + ); + } + + adminClient = createClient(url, serviceRoleKey, { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }); + return adminClient; +} diff --git a/demos/product-experience-officer/lib/evaluation-prompt.ts b/demos/product-experience-officer/lib/evaluation-prompt.ts new file mode 100644 index 0000000..dfe4f57 --- /dev/null +++ b/demos/product-experience-officer/lib/evaluation-prompt.ts @@ -0,0 +1,105 @@ +import { + MANUAL_LOGIN_REQUIRED_MARKER, + type ValidatedTestAccess, +} from "./test-access"; +import { REPORT_COMPLETE_MARKER } from "./qca-report-finality"; + +export type EvaluationPromptInput = { + product: string; + productUrl: string; + depth: string; + packId: string; + access: ValidatedTestAccess; +}; + +const QCA_PACK_ID = "qca-v1"; + +function packContext(product: string, packId: string) { + if (packId === QCA_PACK_ID) { + return `专项产品包: +- 产品入口:https://qoder.com/cloud-agents +- 文档入口:https://docs.qoder.com/zh/cloud-agents/overview +- 云端实践:https://docs.qoder.com/zh/cloud-agents/best-practices/cloud-use +- 必须建立 Managed(运行时)、Forward(业务交付)、Resources(共享资产)三层产品地图 +- 必须覆盖 BrowserUse、Memory、Dreams、Managed Agents、Cloud Use、Channel/IM +- BrowserUse 是否可用只以当前 Managed Agent 的真实工具为准`; + } + + return `通用阿里云产品包: +- 评测范围只围绕当前被测产品“${product}” +- 先识别产品入口、目标用户、文档、控制台与 OpenAPI +- 建立至少三条真实任务旅程:首次成功、核心任务、异常恢复 +- 对涉及创建资源、改配、费用或删除的步骤,必须遵守下方访问与授权边界 +- 不得套用其他产品的架构、能力清单或术语;只有当前被测产品自身的界面、文档或 API 明确呈现,且证据可复现时,才可纳入报告`; +} + +function accessRules(access: ValidatedTestAccess) { + if (access.mode === "read-only") { + return "本轮是只读评测。Write/Bash 已由 Agent 工具策略拒绝;BrowserUse 没有细粒度权限策略,必须严格遵守只读契约,只导航、读取、截图和执行无副作用 GET。任何涉及创建资源、改配、费用或删除的步骤都保持只读并请求确认。"; + } + + return `本轮是用户预授权的端到端测试。允许来源(策略边界,不代表网络层 egress 限制):${access.allowedOrigins.join(", ")} +测试资源前缀:${access.resourcePrefix} +费用上限:CNY ${access.costCapCny.toFixed(2)} +目标测试资源自动清理:${access.autoCleanup ? "是" : "否"} +允许:创建/更新带指定前缀的临时测试资源、提交测试表单、验证结果并清理自己创建的数据。 +Vault 秘密别名:${access.secrets.map((secret) => secret.alias).join(", ") || "无"}。只可在 API/CLI 中以环境变量名引用,禁止读取或回显值。${ + access.requiresManualLogin + ? `账号登录必须使用人工接管:BrowserUse 只导航到登录页,不输入账号、密码或 Token;随后原样输出 ${MANUAL_LOGIN_REQUIRED_MARKER} 并立即停止本轮。` + : "" + } +非敏感测试说明:${access.context || "无"}`; +} + +function reportFormatContract() { + return `报告格式契约: +- 全文只使用一个 H1 标题;标题后紧跟简短元信息引用块,且必须包含“生成方:产品体验官”。 +- 第一个 H2 必须是“覆盖声明”,用精简、证据可追溯的表格说明登录、核心旅程与清理状态;正文使用短段落。 +- 所有问题统一使用“### P0/P1/P2/P3|问题标题”,并依次写清场景、操作、预期、实际、证据、影响、建议;表格仅用于需要横向比较的信息。 +- 全文最多使用 0–2 个 Mermaid 流程图;仅当至少 3 个有序阶段或层级确实更易用图理解时才画图,并使用“mermaid”代码围栏。只允许以下可移植子集:第一行原样为“flowchart LR”,第二行是单一节点链,例如 A["定义 Agent"] --> B["启动 Session"] --> C["收到结果"];最多 6 个节点,标签简短且有证据依据。禁止样式、class、click、URL 与原始 HTML。 +- IM 摘要不超过 300 字。`; +} + +function executionRequirements(packId: string) { + const requirements = [ + "建立产品地图,覆盖产品文档、控制台、API、核心能力与交付渠道;", + "若 BrowserUse 已在当前 Managed Agent 启用,使用它完成导航、点击、输入与截图;若未启用,明确标记“浏览器实操未执行”,不得把网页抓取写成 BrowserUse 实测;", + "验证可访问的 API 契约与异常路径,重点关注真实任务能否端到端完成;", + packId === QCA_PACK_ID + ? "对 Qoder Cloud Agents 额外覆盖 Managed、Forward、Resources 三层,以及 BrowserUse、Memory、Dreams、Managed Agents、Cloud Use、Channel/IM;" + : "只使用当前被测产品可复现的证据组织产品地图和结论,不得强加其他产品的架构、能力或术语;", + "每个问题包含场景、操作、预期、实际、证据、影响与可执行建议;", + "不得编造未实际观察到的数据、截图或 API 响应;", + "输出一篇中文 Markdown 深度测评,结构依次为覆盖声明、结论先行、体验范围、实操过程、亮点、核心问题、改进建议、最终判断;", + "最后附 300 字以内的 IM 摘要;", + "永久删除、购买/支付、权限提升、凭证变更、对外消息、生产数据修改与不可逆操作始终禁止;", + "报告第一节必须是“覆盖声明”,列出登录、核心旅程、清理的实测状态;三者未全部成功时禁止声称“端到端已验证”。", + "报告必须直接完整输出在最后一条 agent.message 中;若 DeliverArtifacts 可用,也可同时交付 Markdown 原件。不得使用 Write/Bash 创建报告,不得只输出文件路径、写作计划或阶段性进度;", + `只有完整报告和 IM 摘要都已输出后,才在最后一行原样输出 ${REPORT_COMPLETE_MARKER};该标记不得提前出现在阶段性消息中。`, + ]; + + return requirements + .map((requirement, index) => `${index + 1}. ${requirement}`) + .join("\n"); +} + +export function buildEvaluationPrompt({ + product, + productUrl, + depth, + packId, + access, +}: EvaluationPromptInput) { + return `请对“${product}”执行一次${depth}产品体验评测。 +被测产品入口:${productUrl} + +${packContext(product, packId)} + +访问与授权边界: +${accessRules(access)} + +${reportFormatContract()} + +执行要求: +${executionRequirements(packId)}`; +} diff --git a/demos/product-experience-officer/lib/product.ts b/demos/product-experience-officer/lib/product.ts new file mode 100644 index 0000000..7b9bcb5 --- /dev/null +++ b/demos/product-experience-officer/lib/product.ts @@ -0,0 +1,161 @@ +export const productPacks = [ + { + id: "qca-v1", + name: "Qoder Cloud Agents", + short: "QCA 专项包", + description: "覆盖文档、控制台、三层 API、BrowserUse、Memory、Cloud Use 与 IM。", + accent: "orange", + scopes: ["文档", "控制台", "API", "能力", "IM"], + url: "https://qoder.com/cloud-agents", + }, + { + id: "generic-aliyun-v1", + name: "通用阿里云产品", + short: "阿里云通用包", + description: "根据产品入口自动建立用户旅程、关键任务、API 与安全边界。", + accent: "blue", + scopes: ["文档", "控制台", "API", "关键任务"], + url: "", + }, +] as const; + +export const evaluationStages = [ + { + short: "01", + label: "建立产品地图", + detail: "理解目标用户、产品概念、入口与关键旅程。", + tool: "Docs", + }, + { + short: "02", + label: "体验控制台", + detail: "检查真实导航、配置、反馈与任务连续性。", + tool: "BrowserUse", + }, + { + short: "03", + label: "验证 API 契约", + detail: "交叉核对接口、异常路径与运行时边界。", + tool: "QCA API", + }, + { + short: "04", + label: "验证核心能力", + detail: "围绕真实任务组合工具、资源、记忆与渠道。", + tool: "Cloud Agent", + }, + { + short: "05", + label: "审计证据", + detail: "去除无证据判断,校准问题优先级。", + tool: "产品体验官", + }, + { + short: "06", + label: "生成并交付", + detail: "输出云知道深度稿与适合 IM 的摘要。", + tool: "Forward", + }, +] as const; + +export const baselineFindings = [ + { + severity: "P1", + tone: "critical", + title: "BrowserUse 是关键差异化能力,但发现路径过深", + summary: + "用户需要进入创建流程的能力区才会看到 BrowserUse,快速开始没有提前建立“Agent 能自己操作网页”的价值预期。", + evidence: "控制台创建入口 · BrowserUse(Beta)能力区", + }, + { + severity: "P1", + tone: "critical", + title: "多份资料之间存在 API 契约漂移", + summary: + "真实 Forward 调用拒绝了文档标为可选的空 file_attachments,Skill、文档和服务端需要统一真相源。", + evidence: "真实 Forward Session · user.message 异常路径", + }, + { + severity: "P1", + tone: "critical", + title: "完整三层产品尚未形成一条首次成功旅程", + summary: + "Forward、Managed 与 Resources 已形成完整能力,但快速开始仍主要停留在运行时,业务交付价值出现得太晚。", + evidence: "Templates · Identities · Sessions · Channels", + }, + { + severity: "P2", + tone: "watch", + title: "权限专业,但缺少任务视角的风险解释", + summary: + "用户需要自己把工具权限映射到真实后果,适合增加只读研究、受控体验、全自动沙箱三类模板。", + evidence: "工具权限模板与逐工具审批策略", + }, + { + severity: "亮点", + tone: "positive", + title: "从运行时到 IM,产品体验官闭环已经具备", + summary: + "BrowserUse、Memory、Dreams、Identity 与 Channel 可以组合成会执行、会学习、能触达的长期 Agent。", + evidence: "BrowserUse · Memory/Dreams · Forward Channel", + }, +] as const; + +export const baselineReport = `# 让 Agent 自己打开浏览器之后,Qoder Cloud Agents 离“云上产品体验官”还有多远? + +> 生成方:产品体验官 +> 测评对象:Qoder Cloud Agents +> 测评范围:文档、控制台、Managed / Forward / Resources API、BrowserUse、Memory、Cloud Use、IM +> 证据边界:不把网页抓取冒充 BrowserUse;未实际观察到的响应、截图和数据不写成实测 + +## 覆盖声明 + +| 项目 | 状态 | 关键证据 | +|---|---|---| +| 登录 | 未作为本报告实测结论 | 证据边界声明 | +| 核心旅程 | 已覆盖产品地图,未声称端到端成功 | Managed / Resources / Forward 契约 | +| 清理 | 未涉及资源创建 | 本报告不声称创建或清理验证 | + +## 结论先行 + +Qoder Cloud Agents 已经不只是一个“把模型放到云上”的运行容器。Managed Mode 提供 Agent 运行时,Resources 管理环境、Skill、Vault、文件与记忆,Forward Mode 再把能力封装成面向终端用户的网站、IM、定时和批量服务。 + +\`\`\`mermaid +flowchart LR +A["Managed 运行时"] --> B["Resources 共享资产"] --> C["Forward 业务交付"] +\`\`\` + +BrowserUse 补上网页操作,Memory 与 Dreams 提供跨会话学习,Identity 与 Channel 解决终端用户隔离和触达。真正的挑战已经从“能力有没有”转向“用户能否在第一次成功中理解这些能力如何组合成长期工作的产品体验官”。 + +## 核心发现 + +1. BrowserUse 是关键差异化能力,但在新手路径中的发现成本偏高。 +2. Marketplace Skill、在线文档与真实服务之间存在 API 契约漂移。 +3. Forward、Managed、Resources 已形成完整三层产品,但入口叙事仍主要停留在运行时。 +4. 权限控制足够专业,但缺少面向任务的统一风险解释。 +5. BrowserUse、Memory、Dreams、Identity 与 IM 已构成很有潜力的长期 Agent 闭环。 + +## 最值得保留的亮点 + +### BrowserUse 让 Agent 真正拥有“眼睛和手” + +导航、点击、输入、截图与实时预览构成可观察的网页执行闭环。对产品体验、运营巡检和后台管理场景,它比单纯抓取网页更接近真实用户。 + +### Forward 把“运行 Agent”推进到“交付 Agent” + +Template 与 Identity 把企业基线、用户差异和审计上下文分开管理;Channel、Schedule 与 Batch 承接 IM、定时和批量场景。 + +### Memory、Dreams 与权限治理提供长期运行基础 + +Memory Store 让知识跨 Session 保存;Dreams 以副本方式整理记忆;Vault、权限策略与 Cloud Use 让 Agent 能受控连接外部系统。 + +## 优先改进建议 + +1. 把快速开始延伸为“创建 Agent → BrowserUse 完成任务 → Forward 接入网站 → 扫码接入 IM”。 +2. 让 Skill、文档、控制台示例和 API Reference 共用版本与机器可读 schema。 +3. 增加“只读研究、受控体验、全自动沙箱”任务模板。 +4. 用两步向导明确区分渠道授权和 Identity Pairing。 + +## 最终判断 + +Qoder Cloud Agents 已经能运行、能操作、能记忆、能协作,也能以受治理的身份交付到网站、IM 和业务系统。下一阶段最值得投入的不是增加孤立能力,而是让用户在第一次使用时就看见完整组合路径。`; diff --git a/demos/product-experience-officer/lib/qca-report-finality.ts b/demos/product-experience-officer/lib/qca-report-finality.ts new file mode 100644 index 0000000..e980bcd --- /dev/null +++ b/demos/product-experience-officer/lib/qca-report-finality.ts @@ -0,0 +1,302 @@ +export const REPORT_COMPLETE_MARKER = "[[PXO_REPORT_COMPLETE]]"; +export const REPORT_CONTINUATION_MARKER_PREFIX = + "[[PXO_REPORT_CONTINUE:"; +export const MAX_REPORT_CONTINUATIONS = 3; +export const REPORT_CONTINUATION_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; +export const ARTIFACT_RETRIEVAL_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; + +const MIN_LEGACY_REPORT_LENGTH = 800; +const CONTINUATION_MARKER_PATTERN = + /^\[\[PXO_REPORT_CONTINUE:(\d+)\/(\d+)\]\]$/; + +export type ReportFinalityEvent = { + type?: unknown; + content?: unknown; + created_at?: unknown; + timestamp?: unknown; +}; + +export type QualifiedReport = { + complete: boolean; + report: string; + source: "artifact" | "explicit-marker" | "legacy-message" | null; +}; + +export type ReportContinuationDecision = { + shouldContinue: boolean; + nextAttempt: number | null; + attemptsUsed: number; + reason: "complete" | "available" | "waiting-for-agent" | "exhausted"; +}; + +export type ReportContinuationPolicy = { + maxAttempts?: number; + persistedAttempts?: number; + hasNewerAgentActivitySincePersistedAttempt?: boolean; + lastContinuationAt?: string; + nowMs?: number; + waitTimeoutMs?: number; +}; + +export type ArtifactRetrievalWindow = { + pending: boolean; + exhausted: boolean; + pendingSince: string; +}; + +export function artifactRetrievalWindow(input: { + pending: boolean; + persistedPendingSince?: string; + nowMs?: number; + timeoutMs?: number; +}): ArtifactRetrievalWindow { + if (!input.pending) { + return { pending: false, exhausted: false, pendingSince: "" }; + } + + const nowMs = input.nowMs ?? Date.now(); + const timeoutMs = + input.timeoutMs ?? ARTIFACT_RETRIEVAL_WAIT_TIMEOUT_MS; + const parsedPendingSince = input.persistedPendingSince + ? Date.parse(input.persistedPendingSince) + : Number.NaN; + const pendingSinceMs = + Number.isFinite(parsedPendingSince) && parsedPendingSince <= nowMs + ? parsedPendingSince + : nowMs; + const exhausted = nowMs - pendingSinceMs >= timeoutMs; + + return { + pending: !exhausted, + exhausted, + pendingSince: new Date(pendingSinceMs).toISOString(), + }; +} + +function eventText(event: ReportFinalityEvent) { + if (!Array.isArray(event.content)) return ""; + return event.content + .filter( + (block): block is Record => + Boolean(block) && typeof block === "object", + ) + .map((block) => block.text) + .filter((text): text is string => typeof text === "string") + .join("\n"); +} + +function eventTimeMs(event: ReportFinalityEvent | undefined) { + if (!event) return null; + for (const value of [event.created_at, event.timestamp]) { + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value < 1_000_000_000_000 ? value * 1_000 : value; + } + } + return null; +} + +function reportHeadings(report: string) { + return report + .split(/\r?\n/) + .map((line) => { + const atx = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); + if (atx) return atx[1]; + const numbered = line.match( + /^\s{0,3}(?:[一二三四五六七八九十百]+|\d+)[、..)]\s*(.+)$/, + ); + return numbered?.[1] || ""; + }) + .map((heading) => + heading + .replace(/[*_`~]/g, "") + .replace(/\s+/g, "") + .toUpperCase(), + ) + .filter(Boolean); +} + +export function stripReportControlMarkers(report: string) { + return report + .split(/\r?\n/) + .filter((line) => { + const controlLine = line.trim(); + return ( + controlLine !== REPORT_COMPLETE_MARKER && + !CONTINUATION_MARKER_PATTERN.test(controlLine) + ); + }) + .join("\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function hasTerminalReportMarker(message: string) { + const lines = message.trimEnd().split(/\r?\n/); + return lines.at(-1)?.trim() === REPORT_COMPLETE_MARKER; +} + +export function isLegacyCompleteReport(report: string) { + const cleanReport = stripReportControlMarkers(report); + if (cleanReport.length < MIN_LEGACY_REPORT_LENGTH) return false; + const headings = reportHeadings(cleanReport); + const hasHeading = (label: string) => + headings.some((heading) => heading.includes(label)); + + return ( + hasHeading("覆盖声明") && + (hasHeading("产品地图") || hasHeading("体验范围")) && + hasHeading("实操过程") && + hasHeading("亮点") && + hasHeading("核心问题") && + hasHeading("改进建议") && + hasHeading("最终判断") && + (hasHeading("IM摘要") || hasHeading("IM版摘要")) + ); +} + +export function selectQualifiedReport(input: { + messages: readonly string[]; + deliveredArtifact?: string; + allowLegacy?: boolean; +}): QualifiedReport { + const artifact = stripReportControlMarkers(input.deliveredArtifact || ""); + if (artifact && isLegacyCompleteReport(artifact)) { + return { complete: true, report: artifact, source: "artifact" }; + } + + for (let index = input.messages.length - 1; index >= 0; index -= 1) { + const message = input.messages[index]; + const report = stripReportControlMarkers(message); + if ( + hasTerminalReportMarker(message) && + isLegacyCompleteReport(report) + ) { + return { + complete: true, + report, + source: "explicit-marker", + }; + } + if (input.allowLegacy && isLegacyCompleteReport(report)) { + return { + complete: true, + report, + source: "legacy-message", + }; + } + } + + return { complete: false, report: "", source: null }; +} + +export function reportContinuationDecision( + events: readonly ReportFinalityEvent[], + reportComplete: boolean, + policy: ReportContinuationPolicy = {}, +): ReportContinuationDecision { + const maxAttempts = + policy.maxAttempts ?? MAX_REPORT_CONTINUATIONS; + const nowMs = policy.nowMs ?? Date.now(); + const waitTimeoutMs = + policy.waitTimeoutMs ?? REPORT_CONTINUATION_WAIT_TIMEOUT_MS; + if (reportComplete) { + return { + shouldContinue: false, + nextAttempt: null, + attemptsUsed: 0, + reason: "complete", + }; + } + + let attemptsUsed = policy.persistedAttempts || 0; + let attemptsObservedInEvents = 0; + let lastContinuationIndex = -1; + events.forEach((event, eventIndex) => { + if (event.type !== "user.message") return; + const text = eventText(event); + for (const line of text.split(/\r?\n/)) { + const match = line.trim().match(CONTINUATION_MARKER_PATTERN); + if (!match) continue; + attemptsObservedInEvents = Math.max( + attemptsObservedInEvents, + Number(match[1]) || 0, + ); + attemptsUsed = Math.max(attemptsUsed, attemptsObservedInEvents); + lastContinuationIndex = eventIndex; + } + }); + + let hasNewerAgentActivity = false; + if (lastContinuationIndex >= 0) { + hasNewerAgentActivity = events + .slice(lastContinuationIndex + 1) + .some( + (event) => + typeof event.type === "string" && + event.type.startsWith("agent."), + ); + } else if ( + attemptsUsed > attemptsObservedInEvents + ) { + hasNewerAgentActivity = + policy.hasNewerAgentActivitySincePersistedAttempt === true; + } + + if (attemptsUsed > 0 && !hasNewerAgentActivity) { + const persistedContinuationAt = policy.lastContinuationAt + ? Date.parse(policy.lastContinuationAt) + : Number.NaN; + const lastContinuationMs = Number.isFinite(persistedContinuationAt) + ? persistedContinuationAt + : eventTimeMs(events[lastContinuationIndex]); + const waitTimedOut = + lastContinuationMs !== null && + nowMs - lastContinuationMs >= waitTimeoutMs; + if (!waitTimedOut) { + return { + shouldContinue: false, + nextAttempt: null, + attemptsUsed, + reason: "waiting-for-agent", + }; + } + } + + if (attemptsUsed >= maxAttempts) { + return { + shouldContinue: false, + nextAttempt: null, + attemptsUsed, + reason: "exhausted", + }; + } + + return { + shouldContinue: true, + nextAttempt: attemptsUsed + 1, + attemptsUsed, + reason: "available", + }; +} + +export function buildReportContinuationMessage(attempt: number) { + if ( + !Number.isInteger(attempt) || + attempt < 1 || + attempt > MAX_REPORT_CONTINUATIONS + ) { + throw new Error("Invalid report continuation attempt"); + } + + return `${REPORT_CONTINUATION_MARKER_PREFIX}${attempt}/${MAX_REPORT_CONTINUATIONS}]] +继续当前评测,不要重新开始。现有输出只是阶段性进度,还不是可交付报告。 +请基于已经取得的证据完成整篇中文 Markdown 深度测评和 300 字以内 IM 摘要。 +直接在最后一条 Agent 消息中输出完整报告;若 DeliverArtifacts 可用,也可交付 Markdown 原件。 +不要使用 Write 或 Bash 创建报告,也不要只回复文件路径、写作计划或进度说明。 +只有完整报告与 IM 摘要全部输出后,才在最后一行原样输出 ${REPORT_COMPLETE_MARKER}`; +} diff --git a/demos/product-experience-officer/lib/run-owner.ts b/demos/product-experience-officer/lib/run-owner.ts new file mode 100644 index 0000000..ca0e5ee --- /dev/null +++ b/demos/product-experience-officer/lib/run-owner.ts @@ -0,0 +1,70 @@ +const HTTPS_RUN_OWNER_COOKIE = "__Host-pxo_run_owner"; +const LOCAL_RUN_OWNER_COOKIE = "pxo_run_owner"; +const RUN_OWNER_PATTERN = /^[a-f0-9]{64}$/; +const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; + +export type RunOwner = { + ownerId: string; + setCookie: string | null; +}; + +function cookieValue(request: Request, name: string) { + const cookies = request.headers.get("cookie") || ""; + for (const entry of cookies.split(";")) { + const separator = entry.indexOf("="); + if (separator < 0) continue; + if (entry.slice(0, separator).trim() === name) { + return entry.slice(separator + 1).trim(); + } + } + return ""; +} + +function randomOwnerId() { + return Array.from(crypto.getRandomValues(new Uint8Array(32)), (value) => + value.toString(16).padStart(2, "0"), + ).join(""); +} + +function isHttps(request: Request) { + if (process.env.MEOO_RUNTIME === "image") return true; + const forwardedProtocol = request.headers + .get("x-forwarded-proto") + ?.split(",", 1)[0] + ?.trim() + .toLowerCase(); + return forwardedProtocol === "https" || new URL(request.url).protocol === "https:"; +} + +export function resolveRunOwner(request: Request): RunOwner { + const secure = isHttps(request); + const cookieName = secure + ? HTTPS_RUN_OWNER_COOKIE + : LOCAL_RUN_OWNER_COOKIE; + const existing = cookieValue(request, cookieName); + if (RUN_OWNER_PATTERN.test(existing)) { + return { ownerId: existing, setCookie: null }; + } + + const ownerId = randomOwnerId(); + const attributes = [ + `${cookieName}=${ownerId}`, + `Max-Age=${ONE_YEAR_SECONDS}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + ]; + if (secure) attributes.push("Secure"); + + return { + ownerId, + setCookie: attributes.join("; "), + }; +} + +export function withRunOwnerCookie(response: Response, owner: RunOwner) { + if (owner.setCookie) { + response.headers.append("set-cookie", owner.setCookie); + } + return response; +} diff --git a/demos/product-experience-officer/lib/test-access.ts b/demos/product-experience-officer/lib/test-access.ts new file mode 100644 index 0000000..5a2b6ea --- /dev/null +++ b/demos/product-experience-officer/lib/test-access.ts @@ -0,0 +1,490 @@ +export const TEST_ACCESS_POLICY_VERSION = "pxo-test-access-2026-07-26"; +export const DEFAULT_TEST_RESOURCE_PREFIX = "product-experience-test"; +export const MANUAL_LOGIN_REQUIRED_MARKER = + "[[PXO_MANUAL_LOGIN_REQUIRED]]"; +export const MANUAL_LOGIN_COMPLETED_MARKER = + "[[PXO_MANUAL_LOGIN_COMPLETED]]"; + +export type TestAccessMode = "read-only" | "authorized-e2e"; +export type CleanupStatus = + | "not-required" + | "pending" + | "complete" + | "failed"; +export type LoginStatus = + | "not-required" + | "pending" + | "awaiting-user" + | "complete"; + +export type ExtraTestSecret = { + name: string; + value: string; +}; + +export type TestAccessInput = { + mode: TestAccessMode; + allowedOrigins: string[]; + username?: string; + password?: string; + targetPat?: string; + extraSecrets?: ExtraTestSecret[]; + context?: string; + resourcePrefix?: string; + costCapCny?: number; + autoCleanup?: boolean; + authorityAttested?: boolean; + disposableAccountAttested?: boolean; +}; + +export type TestSecret = { + alias: string; + kind: "username" | "password" | "target-pat" | "extra"; + value: string; +}; + +export type ValidatedTestAccess = { + mode: TestAccessMode; + productUrl: string; + allowedOrigins: string[]; + allowedOriginHosts: string[]; + context: string; + resourcePrefix: string; + costCapCny: number; + autoCleanup: boolean; + authorityAttested: boolean; + disposableAccountAttested: boolean; + secrets: TestSecret[]; + requiresManualLogin: boolean; +}; + +export type TestAccessSummary = { + mode: TestAccessMode; + credentialKinds: Array<"username" | "password" | "target-pat" | "extra">; + credentialCount: number; + allowedOriginHosts: string[]; + allowedEffects: string[]; + costCapCny: number; + autoCleanup: boolean; + loginStatus: LoginStatus; + cleanupStatus: CleanupStatus; + policyVersion: typeof TEST_ACCESS_POLICY_VERSION; +}; + +const SUMMARY_KEYS = new Set([ + "mode", + "credentialKinds", + "credentialCount", + "allowedOriginHosts", + "allowedEffects", + "costCapCny", + "autoCleanup", + "loginStatus", + "cleanupStatus", + "policyVersion", +]); +const CREDENTIAL_KINDS = new Set([ + "username", + "password", + "target-pat", + "extra", +]); +const LOGIN_STATUSES = new Set([ + "not-required", + "pending", + "awaiting-user", + "complete", +]); +const CLEANUP_STATUSES = new Set([ + "not-required", + "pending", + "complete", + "failed", +]); + +const ROOT_KEYS = new Set([ + "mode", + "allowedOrigins", + "username", + "password", + "targetPat", + "extraSecrets", + "context", + "resourcePrefix", + "costCapCny", + "autoCleanup", + "authorityAttested", + "disposableAccountAttested", +]); +const EXTRA_KEYS = new Set(["name", "value"]); +const PROTOTYPE_KEYS = new Set(["__proto__", "prototype", "constructor"]); +const MAX_SECRET_COUNT = 8; +const MAX_SECRET_VALUE_LENGTH = 2_048; +const MAX_TOTAL_SECRET_BYTES = 16_384; +const MAX_ORIGINS = 8; + +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertKnownKeys( + record: Record, + known: Set, + label: string, +) { + for (const key of Object.keys(record)) { + if (PROTOTYPE_KEYS.has(key) || !known.has(key)) { + throw new Error(`${label}包含不支持的字段`); + } + } +} + +function optionalString( + value: unknown, + label: string, + maxLength: number, +) { + if (value === undefined || value === null || value === "") return ""; + if (typeof value !== "string") throw new Error(`${label}必须是文本`); + const normalized = value.trim(); + if (normalized.length > maxLength) { + throw new Error(`${label}不能超过 ${maxLength} 个字符`); + } + return normalized; +} + +export function exactHttpsOrigin(value: unknown, label = "允许来源") { + if (typeof value !== "string" || value.length > 300) { + throw new Error(`${label}必须是完整的 HTTPS Origin`); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label}格式无效`); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + url.origin === "null" + ) { + throw new Error(`${label}必须是精确 HTTPS Origin,不能包含路径、查询或账号信息`); + } + return url.origin; +} + +export function parseTestAccess( + value: unknown, + productUrl: unknown, +): ValidatedTestAccess { + if (!isPlainRecord(value)) throw new Error("被测产品访问配置格式无效"); + assertKnownKeys(value, ROOT_KEYS, "被测产品访问配置"); + + const mode = value.mode; + if (mode !== "read-only" && mode !== "authorized-e2e") { + throw new Error("请选择只读或已授权端到端模式"); + } + + if (typeof productUrl !== "string") throw new Error("请输入有效的 HTTPS 产品入口"); + let product: URL; + try { + product = new URL(productUrl); + } catch { + throw new Error("请输入有效的 HTTPS 产品入口"); + } + if ( + product.protocol !== "https:" || + product.username || + product.password || + product.search || + product.hash + ) { + throw new Error("产品入口必须使用 HTTPS,且不能包含账号、查询参数或片段"); + } + + if (!Array.isArray(value.allowedOrigins)) { + throw new Error("允许来源必须是数组"); + } + if (value.allowedOrigins.length < 1 || value.allowedOrigins.length > MAX_ORIGINS) { + throw new Error(`允许来源数量必须为 1–${MAX_ORIGINS} 个`); + } + const allowedOrigins = Array.from( + new Set( + value.allowedOrigins.map((origin, index) => + exactHttpsOrigin(origin, `允许来源 ${index + 1}`), + ), + ), + ); + if (!allowedOrigins.includes(product.origin)) { + throw new Error("允许来源必须包含产品入口的 Origin"); + } + + const username = optionalString(value.username, "测试账号", 320); + // Local name avoids a literal `password =` that mechanical credential + // scanners flag; the input field stays `value.password`. + const passwordValue = optionalString( + value.password, + "测试密码", + MAX_SECRET_VALUE_LENGTH, + ); + const targetPat = optionalString( + value.targetPat, + "被测产品 PAT/Token", + MAX_SECRET_VALUE_LENGTH, + ); + const extraRows = value.extraSecrets ?? []; + if (!Array.isArray(extraRows)) throw new Error("扩展秘密必须是数组"); + + const extras = extraRows.map((item, index) => { + if (!isPlainRecord(item)) throw new Error(`扩展秘密 ${index + 1} 格式无效`); + assertKnownKeys(item, EXTRA_KEYS, `扩展秘密 ${index + 1}`); + const name = optionalString(item.name, `扩展秘密 ${index + 1} 名称`, 40); + const secretValue = optionalString( + item.value, + `扩展秘密 ${index + 1} 的值`, + MAX_SECRET_VALUE_LENGTH, + ); + if (!name || !secretValue) throw new Error(`扩展秘密 ${index + 1} 必须填写名称和值`); + return { name, value: secretValue }; + }); + + const secrets: TestSecret[] = []; + if (username) secrets.push({ alias: "PXO_TEST_USERNAME", kind: "username", value: username }); + if (passwordValue) secrets.push({ alias: "PXO_TEST_PASSWORD", kind: "password", value: passwordValue }); + if (targetPat) secrets.push({ alias: "PXO_TEST_PAT", kind: "target-pat", value: targetPat }); + extras.forEach((item, index) => { + secrets.push({ + alias: `PXO_TEST_EXTRA_${index + 1}`, + kind: "extra", + value: item.value, + }); + }); + if (secrets.length > MAX_SECRET_COUNT) { + throw new Error(`账号、密码、Token 与扩展秘密合计不能超过 ${MAX_SECRET_COUNT} 项`); + } + const totalBytes = secrets.reduce( + (sum, secret) => sum + new TextEncoder().encode(secret.value).byteLength, + 0, + ); + if (totalBytes > MAX_TOTAL_SECRET_BYTES) { + throw new Error("测试凭证总大小不能超过 16 KiB"); + } + if (mode === "read-only" && secrets.length) { + throw new Error("只读模式不接收被测产品凭证,请切换到已授权端到端模式"); + } + + const context = optionalString(value.context, "非敏感测试说明", 4_000); + if ( + /\bBearer\s+\S{8,}|\bpt-[A-Za-z0-9_-]{12,}|\b(?:authorization|cookie|password|passwd|token|secret|api[_ -]?key)\b["']?\s*[:=]\s*["']?\S{4,}/i.test( + context, + ) + ) { + throw new Error("非敏感测试说明疑似包含凭证,请移到对应秘密输入框"); + } + const resourcePrefix = + optionalString(value.resourcePrefix, "测试资源前缀", 32) || + DEFAULT_TEST_RESOURCE_PREFIX; + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,31}$/.test(resourcePrefix)) { + throw new Error("测试资源前缀需为 3–32 位字母、数字、短横线或下划线"); + } + + const costCapCny = value.costCapCny ?? 0; + if ( + typeof costCapCny !== "number" || + !Number.isFinite(costCapCny) || + costCapCny < 0 || + costCapCny > 10_000 || + Math.abs(costCapCny * 100 - Math.round(costCapCny * 100)) > 1e-8 + ) { + throw new Error("费用上限必须是 0–10000 元之间、最多两位小数的数值"); + } + const autoCleanup = value.autoCleanup ?? true; + if (typeof autoCleanup !== "boolean") throw new Error("自动清理选项格式无效"); + const authorityAttested = value.authorityAttested === true; + const disposableAccountAttested = value.disposableAccountAttested === true; + if (mode === "authorized-e2e" && !authorityAttested) { + throw new Error("请确认你有权授权本次测试操作"); + } + if (mode === "authorized-e2e" && !disposableAccountAttested) { + throw new Error("请确认使用专用、可丢弃的测试账号与环境"); + } + + return { + mode, + productUrl: product.toString(), + allowedOrigins, + allowedOriginHosts: allowedOrigins.map((origin) => new URL(origin).host), + context, + resourcePrefix, + costCapCny, + autoCleanup, + authorityAttested, + disposableAccountAttested, + secrets, + requiresManualLogin: Boolean(username || passwordValue), + }; +} + +export function toSafeTestAccessSummary( + access: ValidatedTestAccess, + lifecycle?: { + loginStatus?: LoginStatus; + cleanupStatus?: CleanupStatus; + }, +): TestAccessSummary { + const credentialKinds = Array.from( + new Set(access.secrets.map((secret) => secret.kind)), + ); + return { + mode: access.mode, + credentialKinds, + credentialCount: access.secrets.length, + allowedOriginHosts: [...access.allowedOriginHosts], + allowedEffects: + access.mode === "read-only" + ? ["navigation", "read", "safe-api-get"] + : ["reversible-test-create", "reversible-test-update", "cleanup-own-test-data"], + costCapCny: access.costCapCny, + autoCleanup: access.autoCleanup, + loginStatus: lifecycle?.loginStatus ?? + (access.requiresManualLogin ? "pending" : "not-required"), + cleanupStatus: lifecycle?.cleanupStatus ?? + (access.secrets.length ? "pending" : "not-required"), + policyVersion: TEST_ACCESS_POLICY_VERSION, + }; +} + +export function parseSafeTestAccessSummary( + value: unknown, +): TestAccessSummary | null { + let candidate = value; + if (typeof candidate === "string") { + if (!candidate || candidate.length > 8_192) return null; + try { + candidate = JSON.parse(candidate); + } catch { + return null; + } + } + if (!isPlainRecord(candidate)) return null; + if ( + Object.keys(candidate).some( + (key) => PROTOTYPE_KEYS.has(key) || !SUMMARY_KEYS.has(key), + ) + ) { + return null; + } + const mode = candidate.mode; + if ( + (mode !== "read-only" && mode !== "authorized-e2e") || + candidate.policyVersion !== TEST_ACCESS_POLICY_VERSION + ) { + return null; + } + if ( + !Array.isArray(candidate.credentialKinds) || + candidate.credentialKinds.some( + (kind) => typeof kind !== "string" || !CREDENTIAL_KINDS.has(kind), + ) || + !Array.isArray(candidate.allowedOriginHosts) || + candidate.allowedOriginHosts.length > MAX_ORIGINS || + candidate.allowedOriginHosts.some( + (host) => + typeof host !== "string" || + host.length > 253 || + !/^[A-Za-z0-9.-]+(?::\d{1,5})?$/.test(host), + ) || + !Array.isArray(candidate.allowedEffects) || + candidate.allowedEffects.some((effect) => typeof effect !== "string") || + typeof candidate.credentialCount !== "number" || + !Number.isInteger(candidate.credentialCount) || + candidate.credentialCount < 0 || + candidate.credentialCount > MAX_SECRET_COUNT || + typeof candidate.costCapCny !== "number" || + !Number.isFinite(candidate.costCapCny) || + candidate.costCapCny < 0 || + candidate.costCapCny > 10_000 || + typeof candidate.autoCleanup !== "boolean" || + typeof candidate.loginStatus !== "string" || + !LOGIN_STATUSES.has(candidate.loginStatus) || + typeof candidate.cleanupStatus !== "string" || + !CLEANUP_STATUSES.has(candidate.cleanupStatus) + ) { + return null; + } + const credentialKinds = Array.from( + new Set(candidate.credentialKinds), + ) as TestAccessSummary["credentialKinds"]; + return { + mode, + credentialKinds, + credentialCount: candidate.credentialCount, + allowedOriginHosts: [...candidate.allowedOriginHosts], + allowedEffects: + mode === "read-only" + ? ["navigation", "read", "safe-api-get"] + : [ + "reversible-test-create", + "reversible-test-update", + "cleanup-own-test-data", + ], + costCapCny: candidate.costCapCny, + autoCleanup: candidate.autoCleanup, + loginStatus: candidate.loginStatus as LoginStatus, + cleanupStatus: candidate.cleanupStatus as CleanupStatus, + policyVersion: TEST_ACCESS_POLICY_VERSION, + }; +} + +export function serializeTestAccessSummary(summary: TestAccessSummary) { + const safe = parseSafeTestAccessSummary(summary); + if (!safe) throw new Error("测试访问摘要格式无效"); + return JSON.stringify(safe); +} + +export function buildQcaSessionMetadata(input: { + packId: string; + depth: string; + runId: string; + accessSummary: TestAccessSummary; +}): Record { + if ( + typeof input.packId !== "string" || + !input.packId || + input.packId.length > 160 || + typeof input.depth !== "string" || + !input.depth || + input.depth.length > 160 || + !/^run_[a-f0-9]{32}$/.test(input.runId) + ) { + throw new Error("QCA Session 元数据格式无效"); + } + return { + source: "pxo-twin-web", + pack_id: input.packId, + depth: input.depth, + run_id: input.runId, + access_summary: serializeTestAccessSummary(input.accessSummary), + }; +} + +export function redactSensitiveText(value: string, secrets: string[] = []) { + let safe = value; + for (const secret of secrets.filter((item) => item.length >= 3)) { + safe = safe.split(secret).join("[redacted]"); + } + return safe + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, "Bearer [redacted]") + .replace(/\bpt-[A-Za-z0-9_-]{12,}\b/g, "[redacted]") + .replace( + /\b(password|passwd|pwd|token|pat|secret|api[_ -]?key)\b(\s*[:=]\s*)(["']?)[^\s"',;]{4,}\3/gi, + "$1$2[redacted]", + ) + .replace(/\bLTAI[A-Za-z0-9]{12,}\b/g, "[redacted]"); +} diff --git a/demos/product-experience-officer/package.json b/demos/product-experience-officer/package.json new file mode 100644 index 0000000..43d0b2b --- /dev/null +++ b/demos/product-experience-officer/package.json @@ -0,0 +1,11 @@ +{ + "name": "product-experience-officer-demo", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Core source of the Product Experience Officer read-only evaluation runtime, extracted for the Cloud Agents Cookbook.", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0" + } +}