feat: add local learning workbench and PostgreSQL foundation
This commit is contained in:
parent
f76f166241
commit
cd341549dd
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
description: 每轮工作同步维护开发进度与规划方案
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 进度与规划维护
|
||||||
|
|
||||||
|
- 适用本项目每轮开发、修复、验证、讨论、澄清和方案调整。
|
||||||
|
- 开始时读取 `docs/开发进度.md` 当前状态及最新记录,核对 `docs/整体方案-v0.2.md` 的近期实施规划。
|
||||||
|
- 结束前,在 `docs/开发进度.md` 追加本轮记录,写明日期、目标、实际完成、涉及文件、验证结果、未完成项及提交/推送状态;无代码变更时明确说明。
|
||||||
|
- 优化建议、设计决定、优先级和后续实施步骤更新到 `docs/整体方案-v0.2.md`,保持唯一当前规划;调整现有条目,不另建互相冲突的计划。
|
||||||
|
- 进度记录引用方案中的下一步,避免复制维护两份完整待办;新事实改变旧计划时,注明调整原因和影响。
|
||||||
|
- 历史记录保留,新记录可更正旧结论,不能用新结果抹掉失败或未完成状态。
|
||||||
|
- 区分已实现、静态检查、模拟测试、真实浏览器验证、真实平台验证、双端验证和正式发布;写明样本及环境边界,不把阶段测试次数累加或当作当前重跑结果。
|
||||||
|
- 未执行的验证写“未执行”;未提交或未推送如实记录。对话确认不等于代码实现,服务曾启动不等于当前仍在线。
|
||||||
|
- 不在文档中写入平台凭据、Cookie、验证码、数据库连接串、私有配置或原始个人资料。
|
||||||
|
- README 保持文档入口;涉及架构或流程的规划调整同步现有图解,普通进度追加无需重复改图。
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 本项目的本机学习环境,独立于原测试工程。
|
||||||
|
ASSISTANT_API_PORT=8810
|
||||||
|
ASSISTANT_API_TARGET=http://127.0.0.1:8810
|
||||||
|
ASSISTANT_LEARNING_HOME=.runtime/learning
|
||||||
|
# 运行 pnpm db:up 后,脚本会在 .env 中生成独立 PostgreSQL 配置与随机数据库密码。
|
||||||
|
# 以下连接项由脚本写入,无需填写或复用旧工程连接串:
|
||||||
|
# ASSISTANT_POSTGRES_PASSWORD=...
|
||||||
|
# ASSISTANT_POSTGRES_PORT=55432
|
||||||
|
# ASSISTANT_DATABASE_URL=postgresql://.../assistant_learning
|
||||||
|
# ASSISTANT_DATABASE_SCHEMA=assistant_learning
|
||||||
|
# ASSISTANT_RETRIEVAL_DATABASE_URL=postgresql://.../assistant_retrieval
|
||||||
|
# ASSISTANT_TEST_DATABASE_URL=postgresql://.../assistant_learning_test
|
||||||
|
# 默认使用 Playwright Chromium;可在本机开发时改为已安装的 msedge 或 chrome。
|
||||||
|
ASSISTANT_BROWSER_CHANNEL=chromium
|
||||||
|
ASSISTANT_BROWSER_HEADLESS=0
|
||||||
|
|
@ -20,6 +20,8 @@ venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.tsbuildinfo
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# assistant 项目协作约定
|
||||||
|
|
||||||
|
开始本项目工作时,读取 [开发进度](docs/开发进度.md) 的当前状态与最新记录,以及 [整体方案](docs/整体方案-v0.2.md) 的当前实施规划。
|
||||||
|
|
||||||
|
每轮工作遵守 [进度与规划维护规则](.cursor/rules/progress-and-planning.mdc):结束前将本轮实际进展写入进度文档,将优化、设计决定与实施顺序更新到规划方案。纯讨论、澄清和验证轮次也要保留简明记录。
|
||||||
|
|
||||||
|
保护共享工作区已有改动。学习端首期由维护者与本地 Codex 协作学习;实际平台能力、测试、发布与交付状态必须按证据分别报告。
|
||||||
66
README.md
66
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# assistant
|
# assistant
|
||||||
|
|
||||||
面向国内商家的 AI 获客助手,当前处于产品与技术方案讨论阶段。
|
面向国内商家的 AI 获客助手,已开始建设本机学习与验证后台。
|
||||||
|
|
||||||
首版桌面端同时适配 Windows 和 macOS,两端按同一业务范围开发和验收。
|
首版桌面端同时适配 Windows 和 macOS,两端按同一业务范围开发和验收。
|
||||||
|
|
||||||
|
|
@ -8,22 +8,78 @@
|
||||||
|
|
||||||
## 当前文档
|
## 当前文档
|
||||||
|
|
||||||
- [AI 获客助手整体方案](docs/整体方案-v0.2.md):推荐实施路线,覆盖桌面端、学习固化、浏览器执行、AI 服务、计费和开发顺序。
|
- [开发进度](docs/开发进度.md):逐轮记录实际完成、验证、未完成项与提交状态;每轮结束前更新。
|
||||||
|
- [AI 获客助手方案图解](docs/方案图解.html):独立离线 HTML,包含架构、获客、学习发布、AI 额度、任务恢复、登录会话与实施阶段;支持节点查看、路径演示、明暗主题及 SVG、PNG 导出。
|
||||||
|
- [AI 获客助手整体方案](docs/整体方案-v0.2.md):设计决定、优化方向和实施顺序的当前依据,第 14 节维护近期规划;调整时同步现有方案与图解。
|
||||||
- [AI 获客助手初步讨论方案](docs/初步讨论方案-v0.1.md):业务场景、学习固化链路、AI 分工、端形态、收费思路及首版验证范围。
|
- [AI 获客助手初步讨论方案](docs/初步讨论方案-v0.1.md):业务场景、学习固化链路、AI 分工、端形态、收费思路及首版验证范围。
|
||||||
|
|
||||||
## 当前路线
|
## 当前路线
|
||||||
|
|
||||||
- 使用 Electron 开发 Windows、macOS 双平台桌面端。
|
- 使用 Electron 开发 Windows、macOS 双平台桌面端。
|
||||||
- 以 Playwright 固定执行为主线,Midscene 作为可选辅助;A2A 与 browser_use 仅供设计和实现参考,不设优先复用要求。
|
- 以 Playwright 固定执行为主线,Midscene 作为可选辅助;学习后台以 A2A learning 的适用模块为基础裁剪适配,桌面壳另行提取验证。
|
||||||
|
- 学习端围绕少量平台操作完成观察取证、结构化整理、候选生成、固定校验、变参试跑和版本发布,运行异常回流维护端修订。
|
||||||
|
- 首期由开发者与本地 Codex 协作学习:维护者在平台页面登录,Codex 观察、编写参数化实现、提交候选并依据校验和试跑结果修订;后台管理证据与版本。后台内置自主学习 Agent 后续按需求建设。
|
||||||
|
- 学习后台使用独立 PostgreSQL,检索库准备 `pg_search` 与 `pgvector`;商家桌面端仍按 SQLite 规划。平台账号在官方页面自行登录,每个账号复用专用浏览器资料目录。
|
||||||
|
- `browser_use` 测试工程的 Electron 桌面壳、产品认证和管理后台作为候选复用基础;云端可沿用其 FastAPI 骨架,本地 SQLite、获客执行与商业模块按当前方案适配或新增,范围见整体方案第 4.4 节。
|
||||||
- 模型由我们在运营后台按文本判断、复杂复核、视觉操作等用途配置,不绑定具体型号。
|
- 模型由我们在运营后台按文本判断、复杂复核、视觉操作等用途配置,不绑定具体型号。
|
||||||
- 先验证单平台、单账号的搜索到线索流程,再逐步完成跟进、计费和扩展。
|
- 第一条学习链路选定小红书「搜索笔记 → 详情 → 评论读取」,先用本机演示页跑通学习与验证,再在维护者自行登录的平台页面上适配。
|
||||||
|
|
||||||
## 项目状态
|
## 项目状态
|
||||||
|
|
||||||
当前仓库仅包含讨论与整体设计文档,尚未实现获客平台功能。文档中的建议选型、套餐价格、分析额度和成本示例都不是最终产品承诺。
|
当前包含方案文档、学习后台前后端和独立的 Playwright 浏览器工作进程。可以建立维护者账号、创建学习任务、打开浏览器人工登录、观察取证、录制参数化步骤、生成不可变候选以及保存校验和实际试跑记录。第一版操作为搜索、详情和读取,尚未接入 AI 自动探索或完成真实小红书链路验证。
|
||||||
|
|
||||||
|
当前服务是本机维护工具;Electron 商家端、macOS 实机验收、能力正式发布、AI 网关和商业计量尚未完成。文档中的价格、额度和成本示例不是最终产品承诺。
|
||||||
|
|
||||||
本项目使用现有 `assistant` 目录维护,不另建同名项目,也不直接引入完整 A2A 工程作为运行依赖。
|
本项目使用现有 `assistant` 目录维护,不另建同名项目,也不直接引入完整 A2A 工程作为运行依赖。
|
||||||
|
|
||||||
|
学习后台的具体迁入范围见 [模块来源与边界](apps/admin/source-provenance.md)。原 A2A 项目保持只读;本项目未复制原运行数据或账号。
|
||||||
|
|
||||||
|
## 本机启动
|
||||||
|
|
||||||
|
需要已启动的 Docker Desktop、Node.js 22.12+、pnpm 和 Python 3.12+。在项目根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\python.exe -m pip install -r apps/server/requirements.txt
|
||||||
|
pnpm db:up
|
||||||
|
pnpm exec playwright install chromium
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS 创建虚拟环境使用 `python3 -m venv .venv`,安装依赖使用 `.venv/bin/python -m pip install -r apps/server/requirements.txt`,其余命令相同。
|
||||||
|
|
||||||
|
打开 [学习工作台](http://127.0.0.1:5179),空数据库首次启动时自动初始化维护者账号 `admin`,初始密码 `123456`。已有账号时跳过初始化,后续重启不会覆盖账号或密码。密码以 scrypt 摘要保存。API 默认端口为 `8810`。可将 `.env.example` 复制为 `.env` 调整本机配置;使用已安装的 Edge 时设置 `ASSISTANT_BROWSER_CHANNEL=msedge`。
|
||||||
|
|
||||||
|
平台登录在工作台打开的浏览器中直接完成。首次需要人工确认当前身份,固定账号资料目录用于后续复用;目前没有自动核验平台身份的适配器。学习账本保存到 PostgreSQL,浏览器资料与证据文件保存在 `.runtime/learning/`,均不提交 Git。
|
||||||
|
|
||||||
|
## 学习数据库
|
||||||
|
|
||||||
|
`pnpm db:up` 创建本项目的独立容器、数据卷和本机连接配置,端口默认为 `127.0.0.1:55432`。使用固定版本的 `paradedb/paradedb:0.26.1-pg17`,包含 PostgreSQL 17、`pg_search 0.26.1` 与 `pgvector 0.8.6`。随机数据库密码和连接串仅写入被 Git 忽略的 `.env`,与后台登录密码分别管理。
|
||||||
|
|
||||||
|
- `assistant_learning`:维护者、任务、观察、候选和验证记录;默认 schema 为 `assistant_learning`。
|
||||||
|
- `assistant_retrieval`:独立能力知识检索底座,业务索引与模型 embedding 尚未接入。
|
||||||
|
- `assistant_learning_test`:隔离工程测试,每轮使用新建的随机 schema。
|
||||||
|
|
||||||
|
已有 SQLite 原型数据时,先停止本项目后台,执行 `pnpm db:migrate-sqlite`,再启动后台。迁移只向空 PostgreSQL 命名空间复制,同一事务核对行数;原 `.runtime/learning/learning.sqlite` 及一致备份均保留,不覆盖已存在的 PostgreSQL 数据。未配置 `ASSISTANT_DATABASE_URL` 时仍可用 SQLite 做离线开发;它不是当前 Docker 学习环境的存储方式。
|
||||||
|
|
||||||
|
`pnpm db:status` 查看容器,`pnpm db:stop` 停止本项目容器并保留数据卷。普通重启不重建数据库。
|
||||||
|
|
||||||
|
## 检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm check
|
||||||
|
pnpm build
|
||||||
|
pnpm test:server
|
||||||
|
pnpm test:learning
|
||||||
|
pnpm db:check
|
||||||
|
node tools/learning/test-browser-worker.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
`test:learning` 使用隔离的测试库/随机 schema、真实浏览器与本机演示页,覆盖参数化步骤、结果断言、证据回看及重启持久化,不访问真实平台账号。未配置测试 DSN 时回退到临时 SQLite,绝不借用开发库。Windows 默认使用本机 Edge,其他系统默认使用已安装的 Playwright Chromium;可用 `ASSISTANT_TEST_BROWSER_CHANNEL` 覆盖。单端通过不会被记作 Windows/macOS 均通过。
|
||||||
|
|
||||||
|
`db:check` 实际验证中文 BM25、平台过滤、搜索索引计划与固定样本向量排序,测试数据在专用测试库中创建后清理。样本向量不能代表模型 embedding、召回率或完整混合检索已完成。
|
||||||
|
|
||||||
## 远程仓库
|
## 远程仓库
|
||||||
|
|
||||||
[zwt-cmd/assistant](https://github.com/zwt-cmd/assistant)
|
[zwt-cmd/assistant](https://github.com/zwt-cmd/assistant)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content="#14695e" />
|
||||||
|
<title>Assistant 学习工作台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "@assistant/admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1 --port 5179 --strictPort",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "6.3.4",
|
||||||
|
"antd": "6.6.4",
|
||||||
|
"lucide-react": "1.53.0",
|
||||||
|
"react": "19.3.0",
|
||||||
|
"react-dom": "19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.20.3",
|
||||||
|
"@types/react": "19.3.0",
|
||||||
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vite": "8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# 学习后台迁移来源与边界
|
||||||
|
|
||||||
|
当前后台以现有 A2A 学习后台裁剪迁入,入口是 `src/A2AWorkbench.tsx`。原项目位于 `D:/qiekj/PenguinUniOS-Workspace/projects/learning`,所属 Git 根为 `D:/qiekj/PenguinUniOS-Workspace`,本轮读取时为 `main`、`db7b54a`。
|
||||||
|
|
||||||
|
已阅读原仓库与学习端两层 `AGENTS.md`。源项目冻结并保持只读;未启动原服务、运行原测试、同步索引或修改源文件。当前工具未提供 CodeGraph,依照文件搜索与源文件审查确定迁移边界。未读取或复制 `.env`、账号、浏览器 profile、运行资料、数据库、构建产物与依赖目录。
|
||||||
|
|
||||||
|
## 直接迁入后裁剪或适配
|
||||||
|
|
||||||
|
所有源路径以下列目录为起点:`apps/console/src/`。
|
||||||
|
|
||||||
|
- `App.tsx` 的 `app-shell/sidebar/workspace/page` 布局与底部账号呈现迁入 `src/A2AWorkbench.tsx`;本地会话与空库管理员初始化采用本项目认证适配器,移除学校/项目范围、原账号角色和写入恢复服务依赖。
|
||||||
|
- `components/SidebarNavigation.tsx` 迁入同名文件,保留原学习分组与学习批次、账本总览、能力实体、脚本测试导航,裁掉本轮没有服务支持的多机、消费与系统管理入口。
|
||||||
|
- `components/States.tsx`、`PageControls.tsx`、`DetailDrawer.tsx` 直接迁入,作为实际使用的状态、分页、补充信息抽屉组件。
|
||||||
|
- `components/ScriptParameterTable.tsx` 保留原参数表主体和约束/用途展示,生成合同导入替换为 `learning-view-model.ts` 的最小本地视图类型。参数与用途来自候选 manifest;未知业务覆盖仍显示待核实。对象/列表名称按本项目参数语义调整。
|
||||||
|
- `components/TestRunHistory.tsx` 保留原测试历史抽屉、表格与分页主体,固定 A2A 包引用改为不可变候选 ID,数据来自本项目 PostgreSQL 中的真实验证记录;SQLite 保留为离线开发与测试适配。
|
||||||
|
- `pages/LearningCampaignPages.tsx` 的列表、标题、筛选、满高表格和分页主体裁剪迁入同名文件。一条本地任务对应一条明确链路的学习批次,不展示未实现的租约、自动发现、来源目录和覆盖闭合状态。
|
||||||
|
- `pages/ProgressPage.tsx` 的账本汇总、来源表、内容分栏与右侧详情轨裁剪迁入同名文件,改为本地任务状态和候选/验证统计,不将观察数推断成已学会数。
|
||||||
|
- `pages/CapabilitiesPage.tsx` 的能力列表、固定版本详情、使用参数/操作链/定位证据 Tabs 迁入 `pages/CandidatesPage.tsx`;生产发布、消费统计、检索描述和语义编辑等未支持分支移除。
|
||||||
|
- `pages/TestsPage.tsx` 的固定版本测试表、能力选择抽屉与记录详情导航裁剪迁入同名文件;使用本地验证详情适配器展示参数、环境、断言、实际输出与证据。未迁入 SSE、执行宿主派发、停止、失败修复和逐帧回放控制。
|
||||||
|
- `styles.css` 直接迁入 `a2a-learning.css`,`capability-parameters.css` 直接迁入同名文件;`migration.css` 补明暗主题、本地录制工作区以及窄屏适配,保持原后台信息密度与表格滚动结构。
|
||||||
|
|
||||||
|
## 保留的本地适配器
|
||||||
|
|
||||||
|
- `api.ts`:本项目 `/api`、同源会话、CSRF、认证过期和结构化错误,不依赖原 API 前缀或 generated contracts。
|
||||||
|
- `App.tsx`:维护者登录;空库首次启动由后台初始化 `admin / 123456`,已有账号及后续密码不在启动时覆盖。保留手动设置表单供内部关闭自动初始化的验证场景使用。
|
||||||
|
- `pages/TasksPage.tsx`:新建任务表单,接平台、入口、目标、本地会话引用与参数。
|
||||||
|
- `pages/TaskDetailPage.tsx`:真实浏览器打开、用户官方页面自行登录、人工确认、观察采集、动作记录与候选生成。使用迁入的 DetailDrawer 查看页面文字。
|
||||||
|
- `pages/ValidationsPage.tsx` 的 `ValidationDetails/ValidationTable`:本地记录详情适配器,独立来源读回未配置时明确显示缺口。
|
||||||
|
- `components/common.tsx` 的资源代次防串写、格式化与 JSON 展示保留;较早参考过 `D:/qiekj/browser_use/console/components/common/PageTools.tsx`。早期重建的四菜单 Ant Design Layout 和概览卡片页已由迁入的 A2A 主界面替换。
|
||||||
|
|
||||||
|
## 没有迁移的业务
|
||||||
|
|
||||||
|
原 `LearningTasksPage.tsx` 是多机外部任务上报监控,依赖 reporter、machine、heartbeat 和事件游标;不将其伪装成本地任务状态。原 production、consumer、semantics、recovery、账号管理、学校/来源身份核验和完整能力包合同也没有复制。相关模块后续按本项目需求与真实接口单独接入。
|
||||||
|
|
||||||
|
路由为 `#/tasks`、`#/tasks/:id`、`#/overview`、`#/candidates`、`#/candidates/:id`、`#/validations`。本机示例只验证工具链,小红书模板不是已学习或已验收能力,macOS 与独立来源读回仍需要后续真实验证。
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
// App shell migrated from A2A learning/apps/console/src/App.tsx.
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Button, Dropdown } from 'antd';
|
||||||
|
import { ChevronUp, LogOut, Moon, Sun, UserRound } from 'lucide-react';
|
||||||
|
import { SidebarNavigation } from './components/SidebarNavigation';
|
||||||
|
import { ManagedCampaignListPage } from './pages/LearningCampaignPages';
|
||||||
|
import { ProgressPage } from './pages/ProgressPage';
|
||||||
|
import { TaskDetailPage } from './pages/TaskDetailPage';
|
||||||
|
import { CandidatesPage } from './pages/CandidatesPage';
|
||||||
|
import { TestsPage } from './pages/TestsPage';
|
||||||
|
import type { AuthStatus } from './types';
|
||||||
|
|
||||||
|
function routePath() { return location.hash.slice(1) || '/tasks'; }
|
||||||
|
export function A2AWorkbench({ auth, onLogout, dark, onTheme }: { auth: AuthStatus; onLogout: () => void; dark: boolean; onTheme: () => void }) {
|
||||||
|
const [path, setPath] = useState(routePath);
|
||||||
|
useEffect(() => { const update = () => setPath(routePath()); window.addEventListener('hashchange', update); return () => window.removeEventListener('hashchange', update); }, []);
|
||||||
|
const navigate = useCallback((next: string) => { location.hash = next; }, []);
|
||||||
|
const category = path.split('/')[1] || 'tasks';
|
||||||
|
const detailId = path.split('/')[2];
|
||||||
|
const accountName = auth.user?.display_name || auth.user?.username || '维护者';
|
||||||
|
let content = <ManagedCampaignListPage navigate={navigate} />;
|
||||||
|
if (category === 'tasks' && detailId) content = <TaskDetailPage key={detailId} taskId={detailId} navigate={navigate} />;
|
||||||
|
if (category === 'overview') content = <ProgressPage navigate={navigate} />;
|
||||||
|
if (category === 'candidates') content = <CandidatesPage key={detailId || 'list'} selectedId={detailId} navigate={navigate} />;
|
||||||
|
if (category === 'validations') content = <TestsPage navigate={navigate} />;
|
||||||
|
return <div className="app-shell">
|
||||||
|
<aside className="sidebar"><SidebarNavigation category={category} navigate={navigate} /><div className="sidebar-footer"><div className="sidebar-identity">
|
||||||
|
<Dropdown menu={{ items: [{ key: 'logout', label: '退出登录', icon: <LogOut size={16} />, onClick: onLogout }] }} trigger={['click']} placement="topLeft"><button type="button" className="sidebar-account" aria-label={`当前账号:${accountName}`}><span className="sidebar-account-avatar"><UserRound size={18} aria-hidden="true" /></span><span className="sidebar-account-label"><span className="sidebar-account-name">{accountName}</span><span className="sidebar-account-meta">本机维护者</span></span><ChevronUp size={16} aria-hidden="true" /></button></Dropdown>
|
||||||
|
</div></div></aside>
|
||||||
|
<div className="workspace"><div className="topbar"><span className="breadcrumbs">学习管理 / {({ tasks: '学习批次', overview: '账本总览', candidates: '能力实体', validations: '脚本测试' } as Record<string, string>)[category] || '学习批次'}</span><Button type="text" aria-label={dark ? '切换浅色主题' : '切换深色主题'} icon={dark ? <Sun size={16} /> : <Moon size={16} />} onClick={onTheme} /></div><main className="page" key={auth.user?.id || auth.user?.username}>{content}</main></div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { App as AntApp, Alert, Button, ConfigProvider, Form, Input, Spin, Tag, Typography, theme as antdTheme } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { api, errorText, setCsrf } from './api';
|
||||||
|
import { A2AWorkbench } from './A2AWorkbench';
|
||||||
|
import type { AuthStatus } from './types';
|
||||||
|
|
||||||
|
function savedTheme() { try { return localStorage.getItem('assistant-admin-theme') === 'dark'; } catch { return false; } }
|
||||||
|
|
||||||
|
function Authentication({ status, onAuthenticated }: { status: AuthStatus; onAuthenticated: (value: AuthStatus) => void }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const setup = !status.initialized;
|
||||||
|
const submit = async (values: { username: string; password: string }) => {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
const response = await api.post<AuthStatus>(setup ? '/auth/setup' : '/auth/login', { username: values.username.trim(), password: values.password });
|
||||||
|
onAuthenticated(response);
|
||||||
|
} catch (err) { setError(errorText(err)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
return <div className="auth-layout"><section className="auth-intro"><a className="brand auth-brand" href="#/overview"><span className="brand-mark">A</span><span>Assistant</span></a><div className="auth-copy"><div className="eyebrow">LEARNING WORKBENCH</div><h1>把页面操作<br />变成可验证的能力</h1><p>从一个明确目标开始,保留观察与证据,整理可复用步骤,并检查不同参数下的实际结果。</p><ol className="auth-points"><li><span>01</span><div>观察真实页面<small>维护者在官方页面自行登录</small></div></li><li><span>02</span><div>固化操作链路<small>候选版本与证据一同保留</small></div></li><li><span>03</span><div>记录每次验证<small>校验、试跑与发布条件分别核对</small></div></li></ol></div><div className="auth-footer">本机维护工作台 · 学习资料独立保存</div></section><section className="auth-form-side"><div className="auth-form-wrap"><Tag bordered={false}>维护者入口</Tag><Typography.Title level={2}>{setup ? '设置维护者账号' : '登录学习工作台'}</Typography.Title><Typography.Paragraph type="secondary">{setup ? '首次使用,为这个学习工作台创建一个维护者账号。' : '使用工作台账号查看学习任务与验证记录。'}</Typography.Paragraph>
|
||||||
|
{error && <Alert type="error" showIcon message={error} className="load-error" />}
|
||||||
|
<Form layout="vertical" requiredMark={false} initialValues={{ username: 'admin' }} onFinish={values => void submit(values)} size="large">
|
||||||
|
<Form.Item name="username" label="维护者账号" rules={[{ required: true, message: '请填写维护者账号' }, { min: 3, message: '账号至少 3 个字符' }]}><Input data-testid="auth-username" autoComplete="username" placeholder="填写维护者账号" maxLength={80} /></Form.Item>
|
||||||
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请填写密码' }, ...(setup ? [{ min: 10, message: '密码至少 10 个字符' }] : [])]}><Input.Password data-testid="auth-password" autoComplete={setup ? 'new-password' : 'current-password'} placeholder={setup ? '设置至少 10 个字符的密码' : '填写工作台密码'} /></Form.Item>
|
||||||
|
{setup && <Form.Item name="confirm" label="确认密码" dependencies={['password']} rules={[{ required: true, message: '请再次填写密码' }, ({ getFieldValue }) => ({ validator(_, value) { return !value || getFieldValue('password') === value ? Promise.resolve() : Promise.reject(new Error('两次填写的密码不一致')); } })]}><Input.Password data-testid="auth-confirm-password" autoComplete="new-password" placeholder="再次填写密码" /></Form.Item>}
|
||||||
|
<Button data-testid="auth-submit" type="primary" htmlType="submit" block loading={busy}>{setup ? '创建账号并进入' : '登录'}</Button>
|
||||||
|
</Form><Typography.Paragraph className="auth-note" type="secondary">这是维护工作台的账号,与小红书等平台账号分开。平台手机号、密码和验证码由你直接在官方页面填写。</Typography.Paragraph></div></section></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [dark, setDark] = useState(savedTheme);
|
||||||
|
const [auth, setAuth] = useState<AuthStatus>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const updateAuth = useCallback((value: AuthStatus) => { setCsrf(value.csrf_token); setAuth(value); }, []);
|
||||||
|
const refreshAuth = useCallback(async () => {
|
||||||
|
setLoading(true); setError('');
|
||||||
|
try { updateAuth(await api.get<AuthStatus>('/auth/status')); }
|
||||||
|
catch (err) { setError(errorText(err)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [updateAuth]);
|
||||||
|
useEffect(() => { void refreshAuth(); }, [refreshAuth]);
|
||||||
|
useEffect(() => { const expired = () => void refreshAuth(); window.addEventListener('assistant:unauthorized', expired); return () => window.removeEventListener('assistant:unauthorized', expired); }, [refreshAuth]);
|
||||||
|
useEffect(() => { document.documentElement.dataset.theme = dark ? 'dark' : 'light'; try { localStorage.setItem('assistant-admin-theme', dark ? 'dark' : 'light'); } catch { /* Theme persistence is optional. */ } }, [dark]);
|
||||||
|
const logout = async () => { try { await api.post('/auth/logout'); await refreshAuth(); } catch (err) { setError(errorText(err)); } };
|
||||||
|
return <ConfigProvider locale={zhCN} theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, token: { colorPrimary: dark ? '#6bb8a8' : '#14695e', borderRadius: 7, fontSize: 14, colorBgLayout: dark ? '#151a1f' : '#f5f7f8', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif' }, components: { Button: { primaryShadow: 'none', defaultShadow: 'none' }, Card: { headerFontSize: 15 }, Menu: { itemHeight: 42, itemMarginInline: 10 } } }}><AntApp>
|
||||||
|
{loading ? <div className="startup-screen"><Spin size="large" /><Typography.Text type="secondary">正在连接学习服务</Typography.Text></div> : error && !auth ? <div className="startup-screen"><Alert type="error" showIcon message="学习服务暂时不可用" description={error} /><Button onClick={() => void refreshAuth()}>重新连接</Button></div> : auth?.authenticated ? <><A2AWorkbench auth={auth} dark={dark} onTheme={() => setDark(value => !value)} onLogout={() => void logout()} />{error && <div className="global-error"><Alert type="error" closable message={error} afterClose={() => setError('')} /></div>}</> : auth ? <Authentication status={auth} onAuthenticated={updateAuth} /> : null}
|
||||||
|
</AntApp></ConfigProvider>;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,39 @@
|
||||||
|
let csrf = '';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, readonly status: number) { super(message); }
|
||||||
|
}
|
||||||
|
export function setCsrf(token?: string) { csrf = token || ''; }
|
||||||
|
export function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : '请求失败,请稍后重试';
|
||||||
|
}
|
||||||
|
async function request<T>(path: string, method = 'GET', body?: unknown): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api${path}`, {
|
||||||
|
method,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(method !== 'GET' ? { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('暂时无法连接学习服务,请检查本机服务是否已启动', 0);
|
||||||
|
}
|
||||||
|
const data: unknown = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = data && typeof data === 'object' && 'detail' in data ? (data as { detail: unknown }).detail : undefined;
|
||||||
|
let message = typeof detail === 'string' ? detail : '请求未能完成';
|
||||||
|
if (detail && typeof detail === 'object' && 'message' in detail && typeof detail.message === 'string') message = detail.message;
|
||||||
|
if (Array.isArray(detail)) message = detail.map(item => typeof item === 'object' && item && 'msg' in item ? String(item.msg) : String(item)).join(';');
|
||||||
|
if (response.status === 401) window.dispatchEvent(new Event('assistant:unauthorized'));
|
||||||
|
throw new ApiError(message, response.status);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, body: unknown = {}) => request<T>(path, 'POST', body),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
.capability-parameters .plain-table {
|
||||||
|
min-width: 1120px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.capability-parameters .plain-table th,
|
||||||
|
.capability-parameters .plain-table td {
|
||||||
|
vertical-align: top;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-label-column {
|
||||||
|
width: 136px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-field-column {
|
||||||
|
width: 176px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-type-column {
|
||||||
|
width: 88px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-required-column {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-description-column {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-purpose-column {
|
||||||
|
width: 208px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-constraint-column {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-field {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-lines > div + div {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 24px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-missing {
|
||||||
|
color: #8a5700;
|
||||||
|
}
|
||||||
|
.capability-parameters .parameter-restriction {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 2px solid #b9790e;
|
||||||
|
color: #8a5700;
|
||||||
|
max-height: 96px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.parameter-restriction strong {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.parameter-restriction span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Button, Drawer } from 'antd';
|
||||||
|
import { FileText, X } from 'lucide-react';
|
||||||
|
|
||||||
|
/** Inspect supplementary data without expanding or resizing the surrounding page. */
|
||||||
|
export function DetailDrawer({
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
label?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return <>
|
||||||
|
<Button type="text" className={`detail-drawer-trigger${className ? ` ${className}` : ''}`}
|
||||||
|
icon={<FileText size={16} aria-hidden="true" />} onClick={() => setOpen(true)}
|
||||||
|
aria-haspopup="dialog">{label ?? `查看${title}`}</Button>
|
||||||
|
<Drawer title={title} open={open} onClose={() => setOpen(false)} width={640}
|
||||||
|
className="detail-drawer" destroyOnHidden closeIcon={<X size={16} aria-label="关闭" />}>
|
||||||
|
{children}
|
||||||
|
</Drawer>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Button } from 'antd';
|
||||||
|
|
||||||
|
export const LIST_PAGE_SIZE = 100;
|
||||||
|
export const LIST_MAX_OFFSET = 10000;
|
||||||
|
|
||||||
|
export function PageControls({
|
||||||
|
offset,
|
||||||
|
total,
|
||||||
|
count,
|
||||||
|
loading,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
offset: number;
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
|
loading: boolean;
|
||||||
|
onChange: (offset: number) => void;
|
||||||
|
}) {
|
||||||
|
const hasNext = offset + LIST_PAGE_SIZE < total;
|
||||||
|
const atLimit = offset + LIST_PAGE_SIZE > LIST_MAX_OFFSET;
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<div className="section-heading">
|
||||||
|
<span className="muted small" aria-live="polite">
|
||||||
|
第 {Math.floor(offset / LIST_PAGE_SIZE) + 1} 页 · 本页 {count} / 共 {total} 条
|
||||||
|
</span>
|
||||||
|
<div className="button-row">
|
||||||
|
{offset > 0 && (
|
||||||
|
<Button className="quiet-button" disabled={loading} onClick={() => onChange(0)}>
|
||||||
|
回到第一页
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="quiet-button"
|
||||||
|
disabled={offset === 0 || loading}
|
||||||
|
onClick={() => onChange(Math.max(0, offset - LIST_PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="quiet-button"
|
||||||
|
disabled={!hasNext || atLimit || loading}
|
||||||
|
onClick={() => onChange(offset + LIST_PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasNext && atLimit && (
|
||||||
|
<p className="muted small">已到接口分页读取上限,请使用筛选缩小范围。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import type {
|
||||||
|
CapabilityDetail, CapabilityParameter, CapabilityParameterUsage, InputGuard,
|
||||||
|
} from '../learning-view-model';
|
||||||
|
import { operationName } from '../learning-view-model';
|
||||||
|
import '../capability-parameters.css';
|
||||||
|
|
||||||
|
const typeNames: Record<CapabilityParameter['value_type'], string> = {
|
||||||
|
string: '文本', integer: '整数', boolean: '是 / 否', object: '对象', array: '列表',
|
||||||
|
};
|
||||||
|
|
||||||
|
function typeName(parameter: CapabilityParameter): string {
|
||||||
|
const descriptor = parameter.typed_descriptor;
|
||||||
|
if (descriptor?.enum) return '选项';
|
||||||
|
const formats: Record<string, string> = {
|
||||||
|
date: '日期', 'date-time': '日期时间', 'year-month': '年月', decimal: '小数', 'file-reference': '文件', 'file-reference-list': '附件列表',
|
||||||
|
};
|
||||||
|
return descriptor?.format ? formats[descriptor.format] ?? typeNames[parameter.value_type] : typeNames[parameter.value_type];
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardName(guard: InputGuard, capability: CapabilityDetail): string {
|
||||||
|
const labels = new Map(capability.parameter_catalog?.parameters.map((item) => [item.key, item.label ?? item.key]));
|
||||||
|
const parts = guard.conditions.map((condition) => {
|
||||||
|
const label = labels.get(condition.input_key) ?? condition.input_key;
|
||||||
|
if (condition.operator === 'present') return `填写${label}`;
|
||||||
|
const value = condition.value === null ? '空值' : typeof condition.value === 'boolean' ? condition.value ? '是' : '否' : String(condition.value);
|
||||||
|
return `${label}${condition.operator === 'not_equals' ? '不为' : '为'}“${value}”`;
|
||||||
|
});
|
||||||
|
return parts.join(guard.match === 'any' ? '或' : '且');
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionalRequirements(parameter: CapabilityParameter, capability: CapabilityDetail): string[] {
|
||||||
|
const contract = capability.parameter_catalog?.input_contract;
|
||||||
|
if (!contract) return [];
|
||||||
|
const labels = new Map(capability.parameter_catalog?.parameters.map((item) => [item.key, item.label ?? item.key]));
|
||||||
|
const result = (contract.conditional_required ?? []).filter((rule) => rule.required.includes(parameter.key))
|
||||||
|
.map((rule) => `${guardName(rule.when, capability)}时必填`);
|
||||||
|
for (const [key, dependencies] of Object.entries(contract.dependentRequired ?? {})) {
|
||||||
|
if (dependencies.includes(parameter.key)) result.push(`填写${labels.get(key) ?? key}时必填`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constraints(parameter: CapabilityParameter, capability: CapabilityDetail): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
if (parameter.requires_non_empty) result.push('执行时不能为空');
|
||||||
|
if (parameter.min_length !== null && parameter.max_length !== null) {
|
||||||
|
result.push(`长度 ${parameter.min_length} 至 ${parameter.max_length} 个字符`);
|
||||||
|
} else {
|
||||||
|
if (parameter.min_length !== null) result.push(`至少 ${parameter.min_length} 个字符`);
|
||||||
|
if (parameter.max_length !== null) result.push(`至多 ${parameter.max_length} 个字符`);
|
||||||
|
}
|
||||||
|
if (parameter.minimum !== null && parameter.maximum !== null) {
|
||||||
|
result.push(`数值 ${parameter.minimum} 至 ${parameter.maximum}`);
|
||||||
|
} else {
|
||||||
|
if (parameter.minimum !== null) result.push(`数值至少 ${parameter.minimum}`);
|
||||||
|
if (parameter.maximum !== null) result.push(`数值至多 ${parameter.maximum}`);
|
||||||
|
}
|
||||||
|
const descriptor = parameter.typed_descriptor;
|
||||||
|
if (descriptor?.enum) {
|
||||||
|
const options = descriptor.enum.filter((value) => value !== null);
|
||||||
|
result.push(`可选值:${options.slice(0, 6).join('、')}${options.length > 6 ? `等${options.length}项` : ''}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(descriptor?.type) && descriptor.type.includes('null')) result.push('可留空');
|
||||||
|
if (descriptor?.decimal_minimum !== undefined && descriptor.decimal_minimum !== null) result.push(`数值至少 ${descriptor.decimal_minimum}`);
|
||||||
|
if (descriptor?.decimal_maximum !== undefined && descriptor.decimal_maximum !== null) result.push(`数值至多 ${descriptor.decimal_maximum}`);
|
||||||
|
if (descriptor?.decimal_places !== undefined && descriptor.decimal_places !== null) result.push(`最多 ${descriptor.decimal_places} 位小数`);
|
||||||
|
const fileDescriptor = descriptor?.type === 'array' ? descriptor.items : descriptor;
|
||||||
|
if (descriptor?.type === 'array') result.push(`每次 ${descriptor.minItems ?? 1} 至 ${descriptor.maxItems ?? 5} 个文件`);
|
||||||
|
if (fileDescriptor?.max_bytes) result.push(`每个文件不超过 ${fileDescriptor.max_bytes} 字节`);
|
||||||
|
if (fileDescriptor?.file_types?.length) result.push(`扩展名:${[...new Set(fileDescriptor.file_types.map((item) => item.extension))].join('、')}`);
|
||||||
|
if (fileDescriptor?.media_types?.length) {
|
||||||
|
const fileTypes: Record<string, string> = {
|
||||||
|
'application/pdf': 'PDF', 'image/png': 'PNG图片', 'image/jpeg': 'JPEG图片', 'image/gif': 'GIF图片',
|
||||||
|
'image/svg+xml': 'SVG图片', 'image/webp': 'WebP图片', 'text/csv': 'CSV表格',
|
||||||
|
'application/vnd.ms-excel': 'Excel表格', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel表格',
|
||||||
|
};
|
||||||
|
result.push(`文件格式:${[...new Set(fileDescriptor.media_types.map((value) => fileTypes[value] ?? '其他已声明格式'))].join('、')}`);
|
||||||
|
}
|
||||||
|
result.push(...conditionalRequirements(parameter, capability));
|
||||||
|
const labels = new Map(capability.parameter_catalog?.parameters.map((item) => [item.key, item.label ?? item.key]));
|
||||||
|
for (const relation of capability.parameter_catalog?.input_contract?.field_relations ?? []) {
|
||||||
|
if (relation.left === parameter.key) result.push(`应${relation.operator === 'less_than' ? '小于' : '不大于'}${labels.get(relation.right) ?? relation.right}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageName(usage: CapabilityParameterUsage, capability: CapabilityDetail): string {
|
||||||
|
const stepIndex = capability.steps.findIndex((item) => item.step_id === usage.step_id);
|
||||||
|
const purpose = usage.kind === 'runtime_filter' ? '筛选执行结果' : usage.kind === 'action_guard' ? '决定是否执行' : '作为操作输入';
|
||||||
|
const typedOperations: Record<string, string> = {
|
||||||
|
selectCustomOption: '选择选项', selectCalendarDate: '选择日期', uploadFile: '上传文件', downloadFile: '下载文件',
|
||||||
|
};
|
||||||
|
const operation = usage.op ? typedOperations[usage.op] ?? operationName(usage.op) : null;
|
||||||
|
return [purpose, stepIndex >= 0 ? `步骤 ${stepIndex + 1}` : null, operation,
|
||||||
|
usage.guard ? `${guardName(usage.guard, capability)}时执行` : null].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptParameterTable({ capability }: { capability: CapabilityDetail }) {
|
||||||
|
const catalog = capability.parameter_catalog;
|
||||||
|
const parameters = catalog?.parameters ?? [];
|
||||||
|
const complete = catalog?.execution_usage_complete === true;
|
||||||
|
const restricted = catalog?.business_coverage_state === 'restricted';
|
||||||
|
const described = parameters.filter((parameter) => parameter.label !== null && parameter.description !== null).length;
|
||||||
|
const unmappedNames = [...new Set(catalog?.business_field_issues?.map((issue) => issue.name) ?? [])];
|
||||||
|
const empty = !catalog ? '参数定义尚未提供,暂时无法确认使用要求。'
|
||||||
|
: restricted ? '此版本未提供可配置的调用参数。'
|
||||||
|
: complete ? '此能力无需输入参数。' : '当前没有可展示参数,执行用途仍待核实。';
|
||||||
|
return <section className="script-parameter-table capability-parameters table-fill">
|
||||||
|
{catalog && <div className="parameter-status" role="status">
|
||||||
|
<span>{parameters.length} 项调用参数</span>
|
||||||
|
{parameters.length > 0 && <span className={described === parameters.length ? undefined : 'parameter-missing'}>名称与说明:{described} / {parameters.length} 项齐全</span>}
|
||||||
|
<span className={complete ? undefined : 'parameter-missing'}>执行引用:{complete ? '已核对' : '尚未完整核对'}</span>
|
||||||
|
{!restricted && <span className="parameter-missing">业务参数覆盖:待核实</span>}
|
||||||
|
</div>}
|
||||||
|
{restricted && <div className="parameter-restriction" role="note">
|
||||||
|
<strong>受限实例 · 不能作为通用能力使用</strong>
|
||||||
|
<span>未参数化字段:{unmappedNames.length > 0 ? unmappedNames.join('、') : '尚未列明'}</span>
|
||||||
|
</div>}
|
||||||
|
<div className="table-wrap table-scroll" tabIndex={0} aria-label="能力使用参数列表">
|
||||||
|
<table className="plain-table compact">
|
||||||
|
<thead><tr>
|
||||||
|
<th scope="col" className="parameter-label-column">名称</th>
|
||||||
|
<th scope="col" className="parameter-field-column">字段</th>
|
||||||
|
<th scope="col" className="parameter-type-column">类型</th>
|
||||||
|
<th scope="col" className="parameter-required-column">必填</th>
|
||||||
|
<th scope="col" className="parameter-description-column">说明</th>
|
||||||
|
<th scope="col" className="parameter-purpose-column">作用</th>
|
||||||
|
<th scope="col" className="parameter-constraint-column">约束</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{parameters.length > 0 ? parameters.map((parameter) => {
|
||||||
|
const limits = constraints(parameter, capability);
|
||||||
|
const conditional = !parameter.required && conditionalRequirements(parameter, capability).length > 0;
|
||||||
|
return <tr key={parameter.key}>
|
||||||
|
<td>{parameter.label ?? <span className="parameter-missing">未标注名称</span>}</td>
|
||||||
|
<td className="parameter-field">{parameter.key}</td>
|
||||||
|
<td>{typeName(parameter)}</td>
|
||||||
|
<td>{parameter.required ? '必填' : conditional ? '条件必填' : '可选'}
|
||||||
|
{!parameter.schema_required && parameter.execution_required && <small className="muted">执行需要</small>}</td>
|
||||||
|
<td>{parameter.description ?? <span className="parameter-missing">未提供说明</span>}</td>
|
||||||
|
<td className="parameter-lines">{parameter.usages.length > 0 ? parameter.usages.map((usage, index) =>
|
||||||
|
<div key={`${usage.kind}/${usage.step_id ?? ''}/${usage.binding_ref ?? ''}/${index}`}>{usageName(usage, capability)}</div>)
|
||||||
|
: <span className={complete ? 'muted' : 'parameter-missing'}>{complete ? '未被固定执行步骤引用' : '执行用途待核实'}</span>}</td>
|
||||||
|
<td className="parameter-lines">{limits.length > 0 ? limits.map((text) => <div key={text}>{text}</div>)
|
||||||
|
: <span className="muted">未声明额外约束</span>}</td>
|
||||||
|
</tr>;
|
||||||
|
}) : <tr><td colSpan={7} className={complete ? 'muted' : 'parameter-missing'}>{empty}</td></tr>}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
// Migrated from A2A learning/apps/console/src/components/SidebarNavigation.tsx.
|
||||||
|
// Retains the original navigation structure; unsupported distributed/consumer modules are trimmed.
|
||||||
|
import { BookOpen, Boxes, ListTree, PanelsTopLeft, Play, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NavigationItem { label: string; icon: LucideIcon; destination: string; selected: boolean; testId?: string }
|
||||||
|
interface NavigationGroup { label: string; items: readonly NavigationItem[] }
|
||||||
|
|
||||||
|
export function SidebarNavigation({ category, navigate }: { category: string; navigate: (path: string) => void }) {
|
||||||
|
const groups: NavigationGroup[] = [{ label: '学习', items: [
|
||||||
|
{ label: '学习批次', icon: BookOpen, destination: '/tasks', selected: category === 'tasks', testId: 'nav-tasks' },
|
||||||
|
{ label: '账本总览', icon: ListTree, destination: '/overview', selected: category === 'overview' },
|
||||||
|
{ label: '能力实体', icon: Boxes, destination: '/candidates', selected: category === 'candidates' },
|
||||||
|
{ label: '脚本测试', icon: Play, destination: '/validations', selected: category === 'validations' },
|
||||||
|
] }];
|
||||||
|
return <>
|
||||||
|
<div className="brand"><span className="brand-mark"><PanelsTopLeft size={20} strokeWidth={2} aria-hidden="true" /></span><span>学习管理</span></div>
|
||||||
|
<nav className="nav-items" aria-label="管理后台导航">{groups.map(group => <div className="nav-group" role="group" aria-label={group.label} key={group.label}>
|
||||||
|
<div className="nav-group-label">{group.label}</div><div className="nav-subitems">{group.items.map(({ label, icon: Icon, destination, selected, testId }) =>
|
||||||
|
<button key={label} type="button" data-testid={testId} className={`nav-item${selected ? ' active' : ''}`} aria-current={selected ? 'page' : undefined} onClick={() => navigate(destination)}>
|
||||||
|
<Icon size={18} strokeWidth={selected ? 2 : 1.75} aria-hidden="true" /><span>{label}</span>
|
||||||
|
</button>)}
|
||||||
|
</div></div>)}
|
||||||
|
</nav>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Button, Spin } from 'antd';
|
||||||
|
import { CircleAlert, Inbox, RefreshCw } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function ResourceState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div className="state-line" role="status">
|
||||||
|
<Spin size="small" />
|
||||||
|
<span>正在读取记录…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!error) return null;
|
||||||
|
return (
|
||||||
|
<div className="state-line error" role="alert">
|
||||||
|
<CircleAlert size={18} />
|
||||||
|
<div>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button
|
||||||
|
className="quiet-button"
|
||||||
|
icon={<RefreshCw size={16} />}
|
||||||
|
onClick={onRetry}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
重新读取
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<Inbox size={32} aria-hidden="true" />
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorMessage({ message }: { message: string | null }) {
|
||||||
|
return message ? (
|
||||||
|
<p className="error-message" role="alert">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Migrated from A2A learning/apps/console/src/components/TestRunHistory.tsx.
|
||||||
|
// Exact candidate id replaces A2A's package_ref; all rows are persisted validations.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Drawer } from 'antd';
|
||||||
|
import { History, X } from 'lucide-react';
|
||||||
|
import { formatTime, StatusTag, useResource } from './common';
|
||||||
|
import { LIST_PAGE_SIZE, PageControls } from './PageControls';
|
||||||
|
import { EmptyState, ResourceState } from './States';
|
||||||
|
import type { PageResult, Validation } from '../types';
|
||||||
|
|
||||||
|
export function TestRunHistory({ candidateId, currentRunId, onSelect }: { candidateId: string; currentRunId?: string; onSelect: (run: Validation) => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const resource = useResource<PageResult<Validation>>(open ? '/learning/validations' : null);
|
||||||
|
const all = (resource.data?.items || []).filter(item => item.candidate_id === candidateId);
|
||||||
|
const items = all.slice(offset, offset + LIST_PAGE_SIZE);
|
||||||
|
return <>
|
||||||
|
<Button className="quiet-button" icon={<History size={16} />} aria-haspopup="dialog" onClick={() => { setOffset(0); setOpen(true); void resource.reload(); }}>测试历史</Button>
|
||||||
|
<Drawer title="测试历史" open={open} onClose={() => setOpen(false)} width={960} className="test-history-drawer" destroyOnHidden closeIcon={<X size={16} aria-label="关闭" />}>
|
||||||
|
<div className="table-fill"><ResourceState loading={resource.loading} error={resource.error || null} onRetry={() => void resource.reload()} />
|
||||||
|
{resource.data && !resource.loading && !resource.error && <>{items.length ? <div className="table-wrap table-scroll" tabIndex={0} aria-label="固定版本测试历史"><table className="plain-table compact"><thead><tr>
|
||||||
|
<th>创建时间</th><th>测试用例</th><th>验证类型</th><th>结果</th><th>操作</th>
|
||||||
|
</tr></thead><tbody>{items.map(run => <tr key={run.id}><td>{formatTime(run.created_at)}</td><td>{run.sample_name || '未命名测试'}</td><td>{run.kind === 'static' ? '静态校验' : '浏览器试跑'}</td><td><StatusTag status={run.status} /></td><td>{run.id === currentRunId ? <span className="muted">当前记录</span> : <button className="link-button" onClick={() => { setOpen(false); onSelect(run); }}>查看</button>}</td></tr>)}</tbody></table></div> : <EmptyState title="暂无测试历史" description="此固定版本尚无测试记录。" />}
|
||||||
|
<div className="list-footer"><PageControls offset={offset} total={all.length} count={items.length} loading={resource.loading} onChange={setOffset} /></div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
// Adapted from the source project's PageTools page/load-error pattern.
|
||||||
|
// API, vocabulary, and request ownership belong to this learning workbench.
|
||||||
|
import { Alert, Button, Empty, Space, Tag, Typography } from 'antd';
|
||||||
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { api, errorText } from '../api';
|
||||||
|
|
||||||
|
export function useResource<T>(path: string | null) {
|
||||||
|
const [data, setData] = useState<T>();
|
||||||
|
const [loading, setLoading] = useState(Boolean(path));
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const generation = useRef(0);
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const id = ++generation.current;
|
||||||
|
if (!path) { setData(undefined); setError(''); setLoading(false); return; }
|
||||||
|
setLoading(true); setError('');
|
||||||
|
try {
|
||||||
|
const value = await api.get<T>(path);
|
||||||
|
if (id === generation.current) setData(value);
|
||||||
|
} catch (err) { if (id === generation.current) setError(errorText(err)); }
|
||||||
|
finally { if (id === generation.current) setLoading(false); }
|
||||||
|
}, [path]);
|
||||||
|
useEffect(() => { setData(undefined); void reload(); return () => { generation.current += 1; }; }, [reload]);
|
||||||
|
return { data, loading, error, reload };
|
||||||
|
}
|
||||||
|
export function Page({ title, description, actions, children }: { title: string; description?: string; actions?: ReactNode; children: ReactNode }) {
|
||||||
|
return <section className="workbench-page"><div className="page-heading"><div><Typography.Title level={3}>{title}</Typography.Title>{description && <Typography.Paragraph type="secondary">{description}</Typography.Paragraph>}</div><Space wrap>{actions}</Space></div>{children}</section>;
|
||||||
|
}
|
||||||
|
export function LoadError({ error, reload }: { error: string; reload: () => unknown }) {
|
||||||
|
return error ? <Alert className="load-error" type="error" showIcon message={error} action={<Button size="small" onClick={() => void reload()}>重试</Button>} /> : null;
|
||||||
|
}
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
draft: '待开始', created: '待开始', awaiting_user: '等待人工登录', waiting_login: '等待人工登录',
|
||||||
|
observing: '观察中', ready: '可观察', active: '进行中', closed: '浏览器已关闭', paused: '已暂停',
|
||||||
|
candidate: '待验证', pending: '待验证', checked: '校验通过', static_passed: '校验通过',
|
||||||
|
validated: '试跑通过', validation_failed: '验证未通过', verified: '已有通过记录', published: '已发布',
|
||||||
|
passed: '通过', failed: '失败', rejected: '未通过', running: '进行中',
|
||||||
|
};
|
||||||
|
export function StatusTag({ status }: { status: string }) {
|
||||||
|
return <Tag color={['passed', 'validated', 'verified', 'published', 'checked', 'static_passed'].includes(status) ? 'success' : ['failed', 'validation_failed', 'rejected'].includes(status) ? 'error' : ['awaiting_user', 'waiting_login', 'candidate', 'pending'].includes(status) ? 'warning' : ['observing', 'ready', 'active', 'running'].includes(status) ? 'processing' : 'default'}>{statusLabels[status] || status}</Tag>;
|
||||||
|
}
|
||||||
|
export const platformLabels: Record<string, string> = { demo: '本机示例', xiaohongshu: '小红书', douyin: '抖音', other: '其他平台' };
|
||||||
|
export function formatTime(value?: string) {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
export function JsonView({ value, compact = false }: { value: unknown; compact?: boolean }) {
|
||||||
|
return <pre className={`json-view${compact ? ' compact' : ''}`}>{JSON.stringify(value ?? {}, null, 2)}</pre>;
|
||||||
|
}
|
||||||
|
export function NoRows({ description }: { description: string }) { return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={description} />; }
|
||||||
|
export function parseObject(value: string, label = '内容'): Record<string, unknown> {
|
||||||
|
let parsed: unknown;
|
||||||
|
try { parsed = JSON.parse(value); } catch { throw new Error(`${label}需要是有效的 JSON 对象`); }
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error(`${label}需要是 JSON 对象,例如 {"keyword":"建材"}`);
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Local view contracts for the migrated A2A console components.
|
||||||
|
// They deliberately contain only fields backed by this workbench's SQLite API.
|
||||||
|
import type { Candidate } from './types';
|
||||||
|
|
||||||
|
export interface InputGuard { match: 'any' | 'all'; conditions: Array<{ input_key: string; operator: string; value: unknown }> }
|
||||||
|
export interface CapabilityParameterUsage { kind: string; step_id?: string; binding_ref?: string; op?: string; guard?: InputGuard }
|
||||||
|
interface Descriptor { enum?: Array<string | number | boolean | null>; format?: string; type?: string | string[]; decimal_minimum?: number | null; decimal_maximum?: number | null; decimal_places?: number | null; items?: Descriptor; minItems?: number; maxItems?: number; max_bytes?: number; file_types?: Array<{ extension: string }>; media_types?: string[] }
|
||||||
|
export interface CapabilityParameter {
|
||||||
|
key: string; label: string | null; description: string | null;
|
||||||
|
value_type: 'string' | 'integer' | 'boolean' | 'object' | 'array';
|
||||||
|
required: boolean; schema_required: boolean; execution_required: boolean; requires_non_empty: boolean;
|
||||||
|
min_length: number | null; max_length: number | null; minimum: number | null; maximum: number | null;
|
||||||
|
typed_descriptor?: Descriptor; usages: CapabilityParameterUsage[];
|
||||||
|
}
|
||||||
|
export interface CapabilityDetail {
|
||||||
|
steps: Array<{ step_id: string; op: string }>;
|
||||||
|
parameter_catalog?: {
|
||||||
|
parameters: CapabilityParameter[]; execution_usage_complete: boolean;
|
||||||
|
business_coverage_state: 'restricted' | 'unverified'; business_field_issues?: Array<{ name: string }>;
|
||||||
|
input_contract?: { conditional_required?: Array<{ required: string[]; when: InputGuard }>; dependentRequired?: Record<string, string[]>; field_relations?: Array<{ left: string; right: string; operator: string }> };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function operationName(kind: string) { return ({ fill: '填写参数', click: '点击元素', read: '读取内容' } as Record<string, string>)[kind] || kind; }
|
||||||
|
export function parameterView(candidate: Candidate): CapabilityDetail {
|
||||||
|
return {
|
||||||
|
steps: candidate.manifest.steps.map((step, index) => ({ step_id: String(index + 1), op: step.kind })),
|
||||||
|
parameter_catalog: {
|
||||||
|
execution_usage_complete: true, business_coverage_state: 'unverified',
|
||||||
|
parameters: Object.entries(candidate.manifest.parameters).map(([key, value]) => {
|
||||||
|
const usages = candidate.manifest.steps.flatMap((step, index) => step.parameter === key ? [{ kind: 'action_input', step_id: String(index + 1), op: step.kind }] : []);
|
||||||
|
return { key, label: key, description: usages.length ? '已绑定到固定操作步骤' : '已登记样本,尚未绑定操作步骤',
|
||||||
|
value_type: typeof value === 'boolean' ? 'boolean' : Number.isInteger(value) ? 'integer' : Array.isArray(value) ? 'array' : value !== null && typeof value === 'object' ? 'object' : 'string',
|
||||||
|
required: usages.length > 0, schema_required: false, execution_required: usages.length > 0, requires_non_empty: false,
|
||||||
|
min_length: null, max_length: null, minimum: null, maximum: null, usages };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './styles.css';
|
||||||
|
import './a2a-learning.css';
|
||||||
|
import './migration.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/* A2A source layout is authoritative. These rules adapt themes and local browser capture controls. */
|
||||||
|
:root { --line: #e8e8ed; --blue: #0066cc; --surface: #fff; --page-bg: #fff; --border: #e8e8ed; --text: #1d1d1f; --soft: #f5f5f7; --accent: #0066cc; --accent-soft: #e7effb; background: var(--surface); color: var(--text); }
|
||||||
|
:root[data-theme="dark"] { --line: #363b43; --blue: #78b7ff; --surface: #1b1e23; --page-bg: #1b1e23; --border: #363b43; --text: #e4e7eb; --soft: #252a31; --accent: #78b7ff; --accent-soft: #283951; background: var(--surface); color: var(--text); }
|
||||||
|
body { color: var(--text); background: var(--surface); }
|
||||||
|
.app-shell { background: var(--surface); }
|
||||||
|
.app-shell .brand { padding: 0 8px; height: auto; min-height: 40px; gap: 8px; font-size: 16px; letter-spacing: 0; }
|
||||||
|
.app-shell .brand-mark { display: grid; width: 32px; height: 32px; background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.sidebar { background: #f8f9fb; }
|
||||||
|
.sidebar-footer { margin-top: auto; }
|
||||||
|
.page .workbench-page { max-width: none; }
|
||||||
|
.page .page-heading h3 { font-size: 24px !important; margin: 0 !important; }
|
||||||
|
.page-heading { margin-bottom: 16px; }
|
||||||
|
.page-heading p { font-size: 14px; }
|
||||||
|
.section-heading { margin-bottom: 16px; }
|
||||||
|
.section-heading h2, .detail-rail h2 { font-size: 18px; }
|
||||||
|
.page-heading h1 { font-size: 24px; }
|
||||||
|
.topbar { border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
||||||
|
.plain-table th { color: var(--muted); }
|
||||||
|
.plain-table td { overflow-wrap: anywhere; }
|
||||||
|
.campaign-entry { max-width: 300px; font-size: 12px; color: var(--muted); }
|
||||||
|
.page-list .facts { margin-top: 0; margin-bottom: 16px; }
|
||||||
|
.capability-detail .tabs-fill { min-height: 360px; }
|
||||||
|
.evidence-source { margin-top: 24px; }
|
||||||
|
.record-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; }
|
||||||
|
.readiness-notice { border-left: 3px solid #bd8a2f; padding: 8px 16px; margin-top: 16px; background: var(--soft); }
|
||||||
|
.readiness-notice p { margin-bottom: 8px; }
|
||||||
|
.demo-status { margin-bottom: 16px; }
|
||||||
|
.parameter-status { flex-wrap: wrap; }
|
||||||
|
.auth-layout { background: var(--surface); }
|
||||||
|
.auth-intro { background: var(--soft); }
|
||||||
|
.auth-brand { padding: 0; }
|
||||||
|
.auth-copy h1 { font-size: 34px; }
|
||||||
|
.startup-screen { background: var(--surface); }
|
||||||
|
:root[data-theme="dark"] .sidebar { background: #20242a; }
|
||||||
|
:root[data-theme="dark"] .nav-item, :root[data-theme="dark"] .sidebar-account { color: var(--text); }
|
||||||
|
:root[data-theme="dark"] .nav-item:hover, :root[data-theme="dark"] .sidebar-account:hover, :root[data-theme="dark"] .clickable-row:hover { background: #2c323a; }
|
||||||
|
:root[data-theme="dark"] .nav-item.active, :root[data-theme="dark"] .sidebar-account-avatar { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
:root[data-theme="dark"] .quiet-control .ant-select-selector, :root[data-theme="dark"] .quiet-button.ant-btn-default, :root[data-theme="dark"] .code-block { background: var(--soft) !important; border-color: var(--soft) !important; color: var(--text); }
|
||||||
|
:root[data-theme="dark"] .table-scroll .plain-table thead th { background: var(--surface); }
|
||||||
|
@media (min-width: 1280px) { .capability-detail .facts { flex-shrink: 0; } .ledger-summary .table-scroll { max-height: 230px; } }
|
||||||
|
@media (max-width: 1279px) { .page-list { min-height: 65vh; } .table-scroll { overflow: auto; } .list-footer { margin-bottom: 16px; } }
|
||||||
|
@media (max-width: 760px) { .sidebar { width: 168px; padding: 12px 8px; } .workspace { margin-left: 168px; } .page { padding: 16px; } .app-shell .brand { font-size: 15px; padding-inline: 4px; } .nav-item { padding-inline: 8px; } .content-split { grid-template-columns: 1fr; } .detail-rail { border-left: 0; border-top: 1px solid var(--line); padding: 16px 0 0; } .topbar { padding-inline: 16px; } .page-heading { align-items: flex-start; flex-wrap: wrap; } }
|
||||||
|
@media (max-width: 500px) { .sidebar { width: 56px; padding: 12px 4px; } .workspace { margin-left: 56px; } .sidebar .brand > span:last-child, .nav-item > span, .nav-group-label, .sidebar-account-label, .sidebar-account > svg { display: none; } .nav-item { padding-inline: 14px; min-height: 40px; } .sidebar-account { padding: 6px 4px; } .topbar .breadcrumbs { font-size: 12px; } }
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
// Migrated from A2A console/pages/CapabilitiesPage.tsx: capability table,
|
||||||
|
// fixed-version detail, parameter/operation/evidence tabs and test history.
|
||||||
|
// Local candidate contracts replace generated A2A package and consumption contracts.
|
||||||
|
import { App, Button, Drawer, Form, Input, Modal, Select, Tabs, Typography } from 'antd';
|
||||||
|
import { ChevronLeft, ChevronRight, Play, RefreshCw, Search } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api, errorText } from '../api';
|
||||||
|
import { JsonView, StatusTag, formatTime, parseObject, useResource } from '../components/common';
|
||||||
|
import { DetailDrawer } from '../components/DetailDrawer';
|
||||||
|
import { EmptyState, ResourceState } from '../components/States';
|
||||||
|
import { PageControls, LIST_PAGE_SIZE } from '../components/PageControls';
|
||||||
|
import { ScriptParameterTable } from '../components/ScriptParameterTable';
|
||||||
|
import { TestRunHistory } from '../components/TestRunHistory';
|
||||||
|
import { operationName, parameterView } from '../learning-view-model';
|
||||||
|
import { ValidationDetails, ValidationTable } from './ValidationsPage';
|
||||||
|
import type { Candidate, LearningTask, PageResult, PublishCheck, RecordedStep, Validation } from '../types';
|
||||||
|
|
||||||
|
function expectedTemplate(candidate: Candidate) {
|
||||||
|
return JSON.stringify(Object.fromEntries(candidate.manifest.steps.filter(step => step.kind === 'read').map(step => [step.output_name || 'results', ''])), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapabilityPage({ candidate, navigate, onRefresh }: { candidate: Candidate; navigate: (path: string) => void; onRefresh: () => unknown }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [tab, setTab] = useState('parameters');
|
||||||
|
const [selectedStep, setSelectedStep] = useState<RecordedStep>();
|
||||||
|
const [busy, setBusy] = useState('');
|
||||||
|
const [verifyOpen, setVerifyOpen] = useState(false);
|
||||||
|
const [record, setRecord] = useState<Validation>();
|
||||||
|
const [gate, setGate] = useState<PublishCheck>();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const validations = useResource<PageResult<Validation>>('/learning/validations');
|
||||||
|
const task = useResource<LearningTask>(`/learning/tasks/${candidate.task_id}`);
|
||||||
|
const records = (validations.data?.items || []).filter(item => item.candidate_id === candidate.id);
|
||||||
|
const view = useMemo(() => parameterView(candidate), [candidate]);
|
||||||
|
useEffect(() => { setTab('parameters'); setRecord(undefined); setGate(undefined); setVerifyOpen(false); setSelectedStep(undefined); form.resetFields(); }, [candidate.id, form]);
|
||||||
|
async function check() {
|
||||||
|
setBusy('check');
|
||||||
|
try { const result = await api.post<Validation>(`/learning/candidates/${candidate.id}/check`); setRecord(result); await validations.reload(); void onRefresh(); }
|
||||||
|
catch (error) { void message.error(errorText(error)); }
|
||||||
|
finally { setBusy(''); }
|
||||||
|
}
|
||||||
|
async function verify() {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const parameters = parseObject(values.parameters, '本次参数');
|
||||||
|
const expected_outputs = parseObject(values.expected_outputs, '预期输出');
|
||||||
|
setBusy('verify');
|
||||||
|
const result = await api.post<Validation>(`/learning/candidates/${candidate.id}/verify`, { parameters, expected_outputs, sample_name: values.sample_name });
|
||||||
|
setVerifyOpen(false); setRecord(result); await validations.reload(); void onRefresh(); void task.reload();
|
||||||
|
void message[result.status === 'passed' ? 'success' : 'warning'](result.status === 'passed' ? '实际试跑通过,记录已保存' : '试跑未通过,请查看实际结果');
|
||||||
|
} catch (error) { if (!(error && typeof error === 'object' && 'errorFields' in error)) void message.error(errorText(error)); }
|
||||||
|
finally { setBusy(''); }
|
||||||
|
}
|
||||||
|
async function preflight() {
|
||||||
|
setBusy('preflight');
|
||||||
|
try { setGate(await api.post<PublishCheck>(`/learning/candidates/${candidate.id}/publish-check`)); }
|
||||||
|
catch (error) { void message.error(errorText(error)); }
|
||||||
|
finally { setBusy(''); }
|
||||||
|
}
|
||||||
|
return <div className="page-list capability-detail">
|
||||||
|
<div className="page-heading"><div className="learning-heading-context"><Button className="quiet-button learning-back" icon={<ChevronLeft size={16} />} onClick={() => navigate('/candidates')}>返回能力列表</Button><div className="learning-heading-text"><h1>{candidate.name}</h1><p className="subtitle">固定候选版本 v{candidate.version}</p></div></div><div className="heading-action button-row">
|
||||||
|
<TestRunHistory candidateId={candidate.id} onSelect={setRecord} />
|
||||||
|
<Button className="quiet-button" loading={busy === 'check'} disabled={Boolean(busy)} onClick={() => void check()}>固定规则校验</Button>
|
||||||
|
<Button type="primary" icon={<Play size={16} />} disabled={Boolean(busy)} onClick={() => { void task.reload(); setVerifyOpen(true); }}>配置测试</Button>
|
||||||
|
<Button className="quiet-button" disabled={Boolean(busy)} loading={busy === 'preflight'} onClick={() => void preflight()}>发布预检</Button>
|
||||||
|
</div></div>
|
||||||
|
<div className="facts"><span>状态 <StatusTag status={candidate.status} /></span><span>创建于 {formatTime(candidate.created_at)}</span><span>内容摘要 <Typography.Text className="digest" copyable>{candidate.digest.slice(0, 16)}</Typography.Text></span><button className="link-button" onClick={() => navigate(`/tasks/${candidate.task_id}`)}>原学习批次</button></div>
|
||||||
|
<Tabs className="tabs-fill" activeKey={tab} onChange={setTab} destroyOnHidden items={[
|
||||||
|
{ key: 'parameters', label: '使用参数', children: <ScriptParameterTable capability={view} /> },
|
||||||
|
{ key: 'steps', label: '操作链', children: <div className="content-split content-fill"><section className="table-fill"><div className="section-heading"><span className="meta">{candidate.manifest.steps.length} 步 · 固定顺序</span></div><div className="table-wrap table-scroll" tabIndex={0} aria-label="固定操作链步骤列表"><table className="plain-table compact"><thead><tr><th style={{ width: 56 }}>顺序</th><th>操作</th><th>参数 / 输出</th><th style={{ width: 72 }}>详情</th></tr></thead><tbody>{candidate.manifest.steps.map((step, index) => <tr key={index}><td>{index + 1}</td><td>{operationName(step.kind)}</td><td>{step.parameter ? `参数 ${step.parameter}` : step.output_name ? `输出 ${step.output_name}` : '固定操作'}</td><td><button className="link-button muted" aria-label={`查看第${index + 1}步详情`} onClick={() => setSelectedStep(step)}><ChevronRight size={18} /></button></td></tr>)}</tbody></table></div></section><aside className="detail-rail"><h2>适用范围</h2><dl className="metadata"><dt>页面入口</dt><dd>{candidate.manifest.entry_url}</dd><dt>测试执行器</dt><dd>本机 Playwright 浏览器</dd><dt>当前会话</dt><dd>{task.data?.browser_readiness?.message || '按学习批次中的人工确认状态执行'}</dd></dl></aside></div> },
|
||||||
|
{ key: 'evidence', label: '定位与证据', children: <div className="table-fill"><div className="section-heading"><h2>步骤依据</h2><span className="meta">对应学习时的实际观察</span></div><div className="table-wrap table-scroll"><table className="plain-table compact"><thead><tr><th>步骤</th><th>页面定位</th><th>证据标识</th></tr></thead><tbody>{candidate.manifest.steps.map((step, index) => <tr key={index}><td>{index + 1}</td><td className="selector">{step.selector}</td><td>{step.evidence_id || '未关联'}</td></tr>)}</tbody></table></div><Button className="quiet-button evidence-source" onClick={() => navigate(`/tasks/${candidate.task_id}`)}>查看学习页面证据</Button></div> },
|
||||||
|
{ key: 'schemas', label: '能力清单', children: <DetailDrawer title="固定版本能力清单" label="查看能力清单"><JsonView value={candidate.manifest} /><p className="digest">{candidate.digest}</p></DetailDrawer> },
|
||||||
|
{ key: 'tests', label: `测试记录 ${records.length}`, children: <div className="table-fill"><ResourceState loading={validations.loading} error={validations.error || null} onRetry={() => void validations.reload()} /><ValidationTable items={records} loading={validations.loading} onSelect={setRecord} /></div> },
|
||||||
|
]} />
|
||||||
|
<Drawer title="操作步骤详情" open={Boolean(selectedStep)} onClose={() => setSelectedStep(undefined)} width={640} destroyOnHidden>{selectedStep && <JsonView value={selectedStep} />}</Drawer>
|
||||||
|
<Modal title="配置固定版本测试" open={verifyOpen} onCancel={() => { if (!busy) setVerifyOpen(false); }} onOk={() => void verify()} okText="执行真实试跑" okButtonProps={{ disabled: task.loading || Boolean(task.error) || task.data?.browser_readiness?.ready !== true }} confirmLoading={busy === 'verify'} width={680} destroyOnHidden>
|
||||||
|
<p className="muted">按本次参数重放固定步骤,结果与预期逐项对照并留档。</p>
|
||||||
|
<ResourceState loading={task.loading} error={task.error || null} onRetry={() => void task.reload()} />
|
||||||
|
{task.data?.browser_readiness && !task.data.browser_readiness.ready && <div className="readiness-notice"><p>{task.data.browser_readiness.message}</p><Button className="quiet-button" onClick={() => { setVerifyOpen(false); navigate(`/tasks/${candidate.task_id}`); }}>前往打开并确认学习浏览器</Button></div>}
|
||||||
|
<Form form={form} layout="vertical" className="modal-form" initialValues={{ sample_name: '', parameters: JSON.stringify(candidate.manifest.parameters, null, 2), expected_outputs: expectedTemplate(candidate) }}>
|
||||||
|
<Form.Item label="测试样本名称" name="sample_name" rules={[{ required: true, message: '请填写本次参数样本名称' }]}><Input placeholder="例如 更换搜索词后的样本" maxLength={100} /></Form.Item>
|
||||||
|
<Form.Item label="输入参数" name="parameters" rules={[{ required: true }]}><Input.TextArea rows={4} className="json-input" spellCheck={false} /></Form.Item>
|
||||||
|
<Form.Item label="核对结果" name="expected_outputs" extra="字段对应读取步骤的输出名称,值为预期包含的文字或文字数组。" rules={[{ required: true }]}><Input.TextArea rows={4} className="json-input" spellCheck={false} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Drawer title="发布条件预检" width={720} open={Boolean(gate)} onClose={() => setGate(undefined)} destroyOnHidden>{gate && <><h2>{gate.ready ? '当前预检条件已满足' : '仍有条件需要补齐'}</h2><p className="muted">当前结果为即时预检;满足条件后才能进入后续发布流程。</p><table className="plain-table"><thead><tr><th>检查项</th><th>结论</th><th>说明</th></tr></thead><tbody>{gate.checks.map(item => <tr key={item.name}><td>{item.name}</td><td><StatusTag status={item.passed ? 'passed' : 'failed'} /></td><td>{item.detail}</td></tr>)}</tbody></table></> }</Drawer>
|
||||||
|
<Drawer title="验证记录详情" width={880} open={Boolean(record)} onClose={() => setRecord(undefined)} destroyOnHidden>{record && <ValidationDetails record={record} />}</Drawer>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CandidatesPage({ selectedId, navigate }: { selectedId?: string; navigate: (path: string) => void }) {
|
||||||
|
const resource = useResource<PageResult<Candidate>>('/learning/candidates');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [state, setState] = useState('');
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const all = (resource.data?.items || []).filter(item => (!state || item.status === state) && `${item.name} ${item.id}`.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
const items = all.slice(offset, offset + LIST_PAGE_SIZE);
|
||||||
|
const selected = resource.data?.items.find(item => item.id === selectedId);
|
||||||
|
if (selectedId) return selected ? <CapabilityPage key={selectedId} candidate={selected} navigate={navigate} onRefresh={resource.reload} /> : <><Button className="quiet-button" onClick={() => navigate('/candidates')}>返回能力列表</Button><ResourceState loading={resource.loading} error={resource.error || (!resource.loading ? '没有找到该候选版本' : null)} onRetry={() => void resource.reload()} /></>;
|
||||||
|
return <div className="page-list"><div className="page-heading"><div><h1>能力实体</h1><p className="subtitle">{resource.data ? `${all.length} 条候选版本 · 本页 ${items.reduce((sum, item) => sum + item.manifest.steps.length, 0)} 个步骤` : '查看固定版本与操作链'}</p></div><Button className="quiet-button" icon={<RefreshCw size={16} />} loading={resource.loading} onClick={() => void resource.reload()}>刷新</Button></div>
|
||||||
|
<div className="filter-bar"><Select aria-label="筛选能力状态" className="quiet-control" value={state} style={{ minWidth: 144 }} onChange={value => { setState(value); setOffset(0); }} options={[{ value: '', label: '全部状态' }, ...[...new Set((resource.data?.items || []).map(item => item.status))].map(value => ({ value, label: <StatusTag status={value} /> }))]} /><Input className="search" aria-label="搜索能力" prefix={<Search size={16} />} value={search} maxLength={200} allowClear placeholder="搜索能力名称或标识" onChange={event => { setSearch(event.target.value); setOffset(0); }} /></div>
|
||||||
|
<div className="table-fill"><ResourceState loading={resource.loading} error={resource.error || null} onRetry={() => void resource.reload()} />{resource.data && !resource.loading && !resource.error && <>{items.length ? <div className="table-wrap table-scroll" tabIndex={0} aria-label="能力实体列表"><table className="plain-table"><thead><tr><th>能力名称</th><th>固定版本</th><th>步骤</th><th>验证状态</th><th>内容摘要</th><th>操作</th></tr></thead><tbody>{items.map(candidate => <tr key={candidate.id}><td>{candidate.name}</td><td>v{candidate.version}</td><td>{candidate.manifest.steps.length}</td><td><StatusTag status={candidate.status} /></td><td className="digest">{candidate.digest.slice(0, 12)}</td><td><button className="link-button" aria-label={`查看${candidate.name}详情`} onClick={() => navigate(`/candidates/${candidate.id}`)}>查看详情</button></td></tr>)}</tbody></table></div> : <EmptyState title={search || state ? '没有匹配的能力' : '还没有能力实体'} description={search || state ? '调整筛选条件。' : '先在学习批次中观察页面并固化一个候选版本。'} />}
|
||||||
|
<div className="list-footer"><PageControls offset={offset} total={all.length} count={items.length} loading={resource.loading} onChange={setOffset} /></div></>}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Migrated and trimmed from A2A console/pages/LearningCampaignPages.tsx.
|
||||||
|
// A local learning task is this product's single-chain campaign; no distributed leases are represented.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { App, Button, Input, Select } from 'antd';
|
||||||
|
import { Plus, RefreshCw, Search, Play } from 'lucide-react';
|
||||||
|
import { api, errorText } from '../api';
|
||||||
|
import { formatTime, platformLabels, StatusTag, useResource } from '../components/common';
|
||||||
|
import { EmptyState, ResourceState } from '../components/States';
|
||||||
|
import { LIST_PAGE_SIZE, PageControls } from '../components/PageControls';
|
||||||
|
import { CreateTaskModal } from './TasksPage';
|
||||||
|
import type { LearningTask, PageResult } from '../types';
|
||||||
|
|
||||||
|
export function ManagedCampaignListPage({ navigate }: { navigate: (path: string) => void }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const resource = useResource<PageResult<LearningTask>>('/learning/tasks');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [platform, setPlatform] = useState('');
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [demoBusy, setDemoBusy] = useState(false);
|
||||||
|
const all = useMemo(() => (resource.data?.items || []).filter(item => (!platform || item.platform === platform) && `${item.name} ${item.goal}`.toLowerCase().includes(search.toLowerCase())), [resource.data, platform, search]);
|
||||||
|
const items = all.slice(offset, offset + LIST_PAGE_SIZE);
|
||||||
|
async function demo() {
|
||||||
|
setDemoBusy(true);
|
||||||
|
try { const result = await api.post<{ task_id: string }>('/learning/demo'); void message.success('本机示例完成,观察和验证记录已保存'); navigate(`/tasks/${result.task_id}`); }
|
||||||
|
catch (error) { void message.error(errorText(error)); void resource.reload(); }
|
||||||
|
finally { setDemoBusy(false); }
|
||||||
|
}
|
||||||
|
return <div className="page-list">
|
||||||
|
<div className="page-heading"><div><h1>学习批次</h1><p className="subtitle">{resource.data ? `${all.length} 个批次` : '批次与入口账本'}</p></div>
|
||||||
|
<div className="heading-action button-row"><Button className="quiet-button" icon={<RefreshCw size={16} />} loading={resource.loading} onClick={() => void resource.reload()}>刷新</Button>
|
||||||
|
<Button className="quiet-button" data-testid="run-local-demo" icon={<Play size={16} />} loading={demoBusy} onClick={() => void demo()}>运行本机示例</Button>
|
||||||
|
<Button type="primary" icon={<Plus size={16} />} disabled={demoBusy} onClick={() => setCreateOpen(true)}>新建批次</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filter-bar"><Select aria-label="目标平台" className="quiet-control" value={platform} onChange={value => { setPlatform(value); setOffset(0); }} options={[{ value: '', label: '全部平台' }, ...Object.entries(platformLabels).map(([value, label]) => ({ value, label }))]} style={{ minWidth: 144 }} />
|
||||||
|
<Input className="search" prefix={<Search size={16} />} value={search} allowClear maxLength={200} aria-label="搜索学习批次" placeholder="按批次名称或目标搜索" onChange={event => { setSearch(event.target.value); setOffset(0); }} />
|
||||||
|
</div>
|
||||||
|
{demoBusy && <p className="muted demo-status" role="status">正在用真实浏览器保存观察、候选和两个参数样本的验证结果…</p>}
|
||||||
|
<div className="table-fill"><ResourceState loading={resource.loading} error={resource.error || null} onRetry={() => void resource.reload()} />
|
||||||
|
{resource.data && !resource.loading && !resource.error && <>{items.length === 0 ? <EmptyState title={search || platform ? '没有符合条件的批次' : '暂无学习批次'} description={search || platform ? '调整筛选条件后重新查询。' : '新建一条明确的学习目标,或运行本机示例检查工具链。'} /> :
|
||||||
|
<div className="table-wrap table-scroll" tabIndex={0} aria-label="学习批次列表" data-testid="task-list"><table className="plain-table"><thead><tr><th>批次</th><th>来源平台</th><th>状态</th><th>根入口</th><th>更新时间</th></tr></thead><tbody>{items.map(item => <tr key={item.id} className="clickable-row" onClick={() => navigate(`/tasks/${item.id}`)}>
|
||||||
|
<td><a href={`#/tasks/${encodeURIComponent(item.id)}`}>{item.name}</a><p className="muted small">{item.goal}</p></td><td>{platformLabels[item.platform]}</td><td><StatusTag status={item.status} /></td><td className="campaign-entry">{item.entry_url}</td><td className="small muted">{formatTime(item.updated_at || item.created_at)}</td>
|
||||||
|
</tr>)}</tbody></table></div>}
|
||||||
|
<div className="list-footer"><PageControls offset={offset} total={all.length} count={items.length} loading={resource.loading} onChange={setOffset} /></div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
<CreateTaskModal open={createOpen} onClose={() => setCreateOpen(false)} onCreated={task => { setCreateOpen(false); navigate(`/tasks/${task.id}`); }} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Adapted from the original A2A ProgressPage's ledger summary, table and detail rail.
|
||||||
|
// Counts describe persisted local tasks and records, with no inferred discovery/coverage states.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Button, Select } from 'antd';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { EmptyState, ResourceState } from '../components/States';
|
||||||
|
import { platformLabels, StatusTag, formatTime, useResource } from '../components/common';
|
||||||
|
import type { LearningTask, Overview, PageResult } from '../types';
|
||||||
|
|
||||||
|
export function ProgressPage({ navigate }: { navigate: (path: string) => void }) {
|
||||||
|
const resource = useResource<Overview>('/learning/overview');
|
||||||
|
const taskResource = useResource<PageResult<LearningTask>>('/learning/tasks');
|
||||||
|
const [selected, setSelected] = useState('all');
|
||||||
|
const campaigns = taskResource.data?.items || [];
|
||||||
|
const platforms = useMemo(() => [...new Set(campaigns.map(item => item.platform))], [campaigns]);
|
||||||
|
const filtered = selected === 'all' ? campaigns : campaigns.filter(item => item.platform === selected);
|
||||||
|
const data = resource.data;
|
||||||
|
const refresh = () => { void resource.reload(); void taskResource.reload(); };
|
||||||
|
return <div className="page-list">
|
||||||
|
<div className="page-heading"><div className="learning-heading-context"><div className="learning-heading-text"><h1>账本总览</h1><p className="subtitle">{data ? `${data.tasks} 个批次 · ${data.candidates} 个候选 · ${data.validations} 条验证` : '查看持久化的学习与验证记录'}</p></div></div><div className="heading-action"><Button className="quiet-button" icon={<RefreshCw size={16} />} loading={resource.loading || taskResource.loading} onClick={refresh}>刷新账本</Button></div></div>
|
||||||
|
<ResourceState loading={resource.loading || taskResource.loading} error={resource.error || taskResource.error || null} onRetry={refresh} />
|
||||||
|
{data && taskResource.data && !resource.error && !taskResource.error && <>
|
||||||
|
<section className="section ledger-summary"><div className="section-heading"><h2>各平台学习批次</h2><span className="meta">按实际任务状态统计</span></div><div className="table-wrap table-scroll" tabIndex={0} aria-label="平台学习总览"><table className="plain-table"><thead><tr><th>来源平台</th><th>批次总数</th><th>待开始</th><th>观察中</th><th>等待人工登录</th><th>会话关闭 / 暂停</th></tr></thead><tbody>{platforms.map(platform => {
|
||||||
|
const rows = campaigns.filter(item => item.platform === platform);
|
||||||
|
return <tr key={platform}><td>{platformLabels[platform]}</td><td>{rows.length}</td><td>{rows.filter(item => ['draft', 'created'].includes(item.status)).length}</td><td>{rows.filter(item => ['observing', 'ready'].includes(item.status)).length}</td><td>{rows.filter(item => ['awaiting_user', 'waiting_login'].includes(item.status)).length}</td><td>{rows.filter(item => ['closed', 'paused'].includes(item.status)).length}</td></tr>;
|
||||||
|
})}</tbody></table></div></section>
|
||||||
|
<div className="content-split content-fill"><section className="table-fill"><div className="section-heading"><h2>学习入口</h2><Select aria-label="筛选来源平台" value={selected} onChange={setSelected} className="quiet-control" style={{ minWidth: 176 }} options={[{ value: 'all', label: '全部平台' }, ...platforms.map(value => ({ value, label: platformLabels[value] }))]} /></div>
|
||||||
|
{filtered.length ? <div className="table-wrap table-scroll" tabIndex={0} aria-label="学习入口列表"><table className="plain-table compact"><thead><tr><th>来源</th><th>学习目标</th><th>状态</th><th>记录时间</th></tr></thead><tbody>{filtered.map(item => <tr key={item.id}><td>{platformLabels[item.platform]}</td><td><a href={`#/tasks/${item.id}`}>{item.name}</a><p className="muted small">{item.goal}</p></td><td><StatusTag status={item.status} /></td><td>{formatTime(item.created_at)}</td></tr>)}</tbody></table></div> : <EmptyState title="暂无学习入口" description="从学习批次页创建目标后,记录将保存在这里。" action={<Button data-testid="view-task-list" onClick={() => navigate('/tasks')}>查看学习批次</Button>} />}
|
||||||
|
</section><aside className="detail-rail"><h2>验证留档</h2><dl className="metadata"><dt>候选固定版本</dt><dd>{data.candidates}</dd><dt>验证通过记录</dt><dd>{data.passed}</dd><dt>验证失败记录</dt><dd>{data.failed}</dd><dt>已发布版本</dt><dd>{data.published}</dd></dl><p className="muted small">Windows 与 macOS 分别保留实际验证证据,平台模板需逐步观察与验收。</p><Button className="quiet-button" onClick={() => navigate('/validations')}>查看脚本测试</Button></aside></div>
|
||||||
|
</>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { App, Alert, Button, Card, Descriptions, Drawer, Form, Image, Input, Modal, Select, Space, Steps, Table, Tabs, Tag, Typography } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, CameraOutlined, ExportOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api, errorText } from '../api';
|
||||||
|
import { JsonView, LoadError, NoRows, Page, StatusTag, formatTime, platformLabels, useResource } from '../components/common';
|
||||||
|
import type { BrowserElement, Candidate, LearningTask, Observation, RecordedStep, Validation } from '../types';
|
||||||
|
import { ValidationDetails, ValidationTable } from './ValidationsPage';
|
||||||
|
import { DetailDrawer } from '../components/DetailDrawer';
|
||||||
|
|
||||||
|
const kindLabels = { fill: '填写参数', click: '点击进入', read: '读取内容' };
|
||||||
|
function guessKind(element: BrowserElement): RecordedStep['kind'] {
|
||||||
|
const type = `${element.kind || ''} ${element.tag || ''}`.toLowerCase();
|
||||||
|
if (/input|textarea|fill/.test(type)) return 'fill';
|
||||||
|
if (/button|link|click|\ba\b/.test(type)) return 'click';
|
||||||
|
return 'read';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetailPage({ taskId, navigate }: { taskId: string; navigate: (path: string) => void }) {
|
||||||
|
const resource = useResource<LearningTask>(`/learning/tasks/${taskId}`);
|
||||||
|
const task = resource.data;
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [busy, setBusy] = useState('');
|
||||||
|
const [readyOpen, setReadyOpen] = useState(false);
|
||||||
|
const [identity, setIdentity] = useState('');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [selectedObservation, setSelectedObservation] = useState<string>();
|
||||||
|
const [selectedSelector, setSelectedSelector] = useState<string>();
|
||||||
|
const [kind, setKind] = useState<RecordedStep['kind']>('read');
|
||||||
|
const [parameter, setParameter] = useState<string>();
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [outputName, setOutputName] = useState('results');
|
||||||
|
const [candidateName, setCandidateName] = useState('');
|
||||||
|
const [candidateOpen, setCandidateOpen] = useState(false);
|
||||||
|
const [record, setRecord] = useState<Validation>();
|
||||||
|
const [activeTab, setActiveTab] = useState('observe');
|
||||||
|
useEffect(() => { setSelectedObservation(undefined); setSelectedSelector(undefined); setReadyOpen(false); setCandidateOpen(false); setIdentity(''); setNote(''); setBusy(''); }, [taskId]);
|
||||||
|
const observations = task?.observations || [];
|
||||||
|
const latest = observations.at(-1);
|
||||||
|
const observation = observations.find(item => item.id === selectedObservation) || latest;
|
||||||
|
const latestElements = latest?.elements || [];
|
||||||
|
const selected = latestElements.find(element => element.selector === selectedSelector);
|
||||||
|
const parameterKeys = Object.keys(task?.parameters || {});
|
||||||
|
const observationItems = useMemo(() => observations.map((item, index) => ({ value: item.id, label: `观察 ${index + 1} · ${item.title || formatTime(item.created_at)}` })), [observations]);
|
||||||
|
const mutation = async <T,>(name: string, path: string, body: unknown = {}): Promise<T | undefined> => {
|
||||||
|
setBusy(name);
|
||||||
|
try { const result = await api.post<T>(path, body); await resource.reload(); return result; }
|
||||||
|
catch (error) { void message.error(errorText(error)); return undefined; }
|
||||||
|
finally { setBusy(''); }
|
||||||
|
};
|
||||||
|
const capture = async () => {
|
||||||
|
const result = await mutation<Observation>('capture', `/learning/tasks/${taskId}/browser/capture`, { note });
|
||||||
|
if (result) { setSelectedObservation(result.id); setSelectedSelector(undefined); setNote(''); void message.success('页面观察与证据已保存'); }
|
||||||
|
};
|
||||||
|
const markReady = async () => {
|
||||||
|
if (!identity.trim()) { void message.warning('请填写这次人工确认的账号标识或昵称'); return; }
|
||||||
|
const result = await mutation('ready', `/learning/tasks/${taskId}/browser/ready`, { identity_label: identity.trim() });
|
||||||
|
if (result) { setReadyOpen(false); void message.success('人工确认已记录,可以采集页面'); }
|
||||||
|
};
|
||||||
|
const execute = async () => {
|
||||||
|
if (!selected) { void message.warning('请从最新观察中选择一个页面元素'); return; }
|
||||||
|
if (kind !== 'read' && selected.unique === false) { void message.warning('该定位匹配多个元素,请重新观察或选择唯一元素'); return; }
|
||||||
|
if (kind === 'fill' && !parameter && !value) { void message.warning('请绑定任务参数或填写本次输入内容'); return; }
|
||||||
|
if (kind === 'read' && !outputName.trim()) { void message.warning('请为读取内容设置输出名称'); return; }
|
||||||
|
const result = await mutation('action', `/learning/tasks/${taskId}/browser/action`, { kind, selector: selected.selector, ...(kind === 'fill' ? parameter ? { parameter } : { value } : {}), ...(kind === 'read' ? { output_name: outputName.trim() } : {}) });
|
||||||
|
if (result) { setSelectedObservation(undefined); setSelectedSelector(undefined); void message.success('操作与前后证据已记录'); }
|
||||||
|
};
|
||||||
|
const createCandidate = async () => {
|
||||||
|
const candidate = await mutation<Candidate>('candidate', `/learning/tasks/${taskId}/candidates`, candidateName.trim() ? { name: candidateName.trim() } : {});
|
||||||
|
if (candidate) { setCandidateOpen(false); navigate(`/candidates/${candidate.id}`); }
|
||||||
|
};
|
||||||
|
const chooseElement = (element: BrowserElement) => {
|
||||||
|
setSelectedSelector(element.selector); setKind(guessKind(element)); setParameter(undefined); setValue('');
|
||||||
|
};
|
||||||
|
if (!task) return <Page title="学习任务" actions={<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/tasks')}>返回任务</Button>}><LoadError error={resource.error} reload={resource.reload} />{resource.loading && <Card loading />}</Page>;
|
||||||
|
const steps = task.steps || [];
|
||||||
|
return <Page title={task.name} description={task.goal} actions={<><Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/tasks')}>任务列表</Button><Button onClick={() => void resource.reload()} loading={resource.loading}>刷新</Button><Button type="primary" icon={<PlusOutlined />} disabled={!steps.length || Boolean(busy)} onClick={() => { setCandidateName(task.name); setCandidateOpen(true); }}>生成候选版本</Button></>}>
|
||||||
|
<LoadError error={resource.error} reload={resource.reload} />
|
||||||
|
{task.browser_readiness && <div className="readiness-notice" role="status"><p>{task.browser_readiness.message}</p><Typography.Text type="secondary">需要试跑时,先打开学习浏览器,在官方页面自行登录,再人工确认会话。</Typography.Text></div>}
|
||||||
|
<div className="task-summary"><Space wrap><Tag>{platformLabels[task.platform] || task.platform}</Tag><StatusTag status={task.status} /><Typography.Text type="secondary">创建于 {formatTime(task.created_at)}</Typography.Text></Space><Typography.Text className="mono task-id" copyable>{task.id}</Typography.Text></div>
|
||||||
|
<Card className="surface-card compact-card"><Descriptions size="small" column={{ xs: 1, md: 2, xl: 3 }} items={[
|
||||||
|
{ key: 'account', label: '本地账号引用', children: task.account_key },
|
||||||
|
{ key: 'entry', label: '页面入口', children: <Typography.Text className="entry-url" ellipsis={{ tooltip: task.entry_url }}>{task.entry_url}</Typography.Text>, span: 2 },
|
||||||
|
{ key: 'parameters', label: '任务参数', children: <code>{JSON.stringify(task.parameters)}</code>, span: 3 },
|
||||||
|
]} /></Card>
|
||||||
|
<div className="phase-strip"><Steps size="small" current={task.candidates?.length ? 2 : steps.length ? 1 : 0} items={[{ title: '观察与记录', description: `${observations.length} 份页面证据` }, { title: '候选固化', description: `${task.candidates?.length || 0} 个不可变版本` }, { title: '参数验证', description: `${task.validations?.length || 0} 条验证记录` }]} /></div>
|
||||||
|
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
|
||||||
|
{ key: 'observe', label: '浏览器与观察', children: <div className="detail-stack">
|
||||||
|
<Card className="surface-card" title="浏览器会话" extra={<Tag>人工登录</Tag>}><div className="session-controls"><Space wrap><Button icon={<ExportOutlined />} loading={busy === 'open'} disabled={Boolean(busy) && busy !== 'open'} onClick={() => void mutation('open', `/learning/tasks/${taskId}/browser/open`)}>打开浏览器</Button><Button onClick={() => setReadyOpen(true)} disabled={Boolean(busy)}>已完成官方页面登录</Button><Button loading={busy === 'close'} disabled={Boolean(busy) && busy !== 'close'} onClick={() => void mutation('close', `/learning/tasks/${taskId}/browser/close`)}>关闭浏览器</Button></Space><Typography.Paragraph type="secondary">在打开的官方页面自行完成扫码或验证码登录。这里仅记录人工确认,尚未接入平台自动身份核验。</Typography.Paragraph></div>
|
||||||
|
<div className="capture-controls"><Input aria-label="观察备注" placeholder="本次观察备注,可选" value={note} onChange={event => setNote(event.target.value)} maxLength={500} /><Button type="primary" icon={<CameraOutlined />} loading={busy === 'capture'} disabled={Boolean(busy) && busy !== 'capture'} onClick={() => void capture()}>采集当前页面</Button></div>
|
||||||
|
</Card>
|
||||||
|
{observation ? <div className="observation-layout"><Card className="surface-card observation-card" title="页面证据" extra={<Select aria-label="切换页面观察" value={observation.id} options={observationItems} onChange={setSelectedObservation} popupMatchSelectWidth={false} style={{ width: 230 }} />}>
|
||||||
|
<div className="observation-meta"><Typography.Text strong>{observation.title || '当前页面'}</Typography.Text><Typography.Paragraph className="evidence-url" type="secondary" copyable>{observation.url}</Typography.Paragraph><Typography.Text type="secondary">{formatTime(observation.created_at)}{observation.note ? ` · ${observation.note}` : ''}</Typography.Text></div>
|
||||||
|
{observation.screenshot_url && <div className="screenshot-frame"><Image src={observation.screenshot_url} alt={`页面观察 ${observation.title || observation.id}`} width="100%" /></div>}
|
||||||
|
<DetailDrawer title="页面文字" label="查看页面文字"><pre className="learning-evidence-content">{observation.text || '本次观察没有可记录的页面文字'}</pre></DetailDrawer>
|
||||||
|
</Card><Card className="surface-card" title="记录下一步操作" extra={<Tag>{selected ? '已选择定位' : '选择页面元素'}</Tag>}>
|
||||||
|
<Form layout="vertical"><Form.Item label="操作类型"><Select value={kind} onChange={setKind} options={Object.entries(kindLabels).map(([value, label]) => ({ value, label }))} /></Form.Item><Form.Item label="页面定位"><Input.TextArea aria-label="选中的页面定位" value={selected?.selector || ''} rows={2} readOnly placeholder="在下方最新观察的元素列表中选择" /></Form.Item>
|
||||||
|
{kind === 'fill' && <><Form.Item label="绑定任务参数"><Select allowClear value={parameter} placeholder="选择已定义的参数,或填写固定值" onChange={setParameter} options={parameterKeys.map(key => ({ value: key, label: `${key} = ${String(task.parameters[key])}` }))} /></Form.Item>{!parameter && <Form.Item label="本次输入内容"><Input value={value} onChange={event => setValue(event.target.value)} autoComplete="off" placeholder="普通搜索或查询内容" /></Form.Item>}</>}
|
||||||
|
{kind === 'read' && <Form.Item label="输出名称"><Input value={outputName} onChange={event => setOutputName(event.target.value)} placeholder="例如 results 或 comments" /></Form.Item>}
|
||||||
|
<Button type="primary" block disabled={!selected || Boolean(busy)} loading={busy === 'action'} onClick={() => void execute()}>执行并记录步骤</Button>
|
||||||
|
</Form><Typography.Paragraph className="form-note" type="secondary">定位来自最新观察,执行前重新核对。首版只读链路;登录表单与发送、关注等动作不进入这条验证流程。</Typography.Paragraph>
|
||||||
|
</Card></div> : <Card className="surface-card"><NoRows description="打开浏览器并完成人工登录确认后,采集第一份页面证据。" /></Card>}
|
||||||
|
<Card className="surface-card" title="最新观察的页面元素" extra={<Tag>{latestElements.length} 个定位候选</Tag>} styles={{ body: { padding: 0 } }}><Table<BrowserElement> rowKey={(item, i) => `${item.selector}-${i}`} size="small" pagination={{ pageSize: 8, showSizeChanger: false }} scroll={{ x: 750 }} dataSource={latestElements} locale={{ emptyText: '采集页面后显示可观察的元素' }} rowClassName={row => row.selector === selectedSelector ? 'selected-element' : ''} columns={[
|
||||||
|
{ title: '元素', dataIndex: 'label', key: 'label', render: (label: string, row) => <div><Typography.Text>{label || row.tag || '页面元素'}</Typography.Text><div className="table-secondary">{row.tag} · {row.kind || '可读取'}</div></div> },
|
||||||
|
{ title: '定位', dataIndex: 'selector', render: (selector: string) => <code className="selector">{selector}</code> },
|
||||||
|
{ title: '匹配', key: 'unique', width: 90, render: (_: unknown, row) => row.sensitive ? <Tag>已排除</Tag> : <Tag>{row.unique === false ? '多匹配' : '唯一'}</Tag> },
|
||||||
|
{ title: '操作', key: 'choose', width: 85, render: (_: unknown, row) => <Button size="small" disabled={row.sensitive || Boolean(busy)} onClick={() => chooseElement(row)}>选择</Button> },
|
||||||
|
]} /></Card>
|
||||||
|
</div> },
|
||||||
|
{ key: 'steps', label: `链路步骤 ${steps.length}`, children: <Card className="surface-card" styles={{ body: { padding: 0 } }}><Table<RecordedStep> rowKey={(_, i) => String(i)} dataSource={steps} pagination={false} scroll={{ x: 760 }} locale={{ emptyText: '通过最新页面元素执行操作后,步骤会保存在这里。' }} columns={[
|
||||||
|
{ title: '顺序', key: 'index', width: 65, render: (_: unknown, __: unknown, i: number) => i + 1 },
|
||||||
|
{ title: '动作', dataIndex: 'kind', width: 100, render: (value: RecordedStep['kind']) => kindLabels[value] },
|
||||||
|
{ title: '页面定位', dataIndex: 'selector', render: (selector: string) => <code className="selector">{selector}</code> },
|
||||||
|
{ title: '取值或输出', key: 'binding', width: 180, render: (_: unknown, step) => step.parameter ? `参数 ${step.parameter}` : step.output_name ? `输出 ${step.output_name}` : step.value || '固定操作' },
|
||||||
|
{ title: '来源观察', dataIndex: 'evidence_id', width: 130, render: (id: string) => id ? <Button type="link" className="table-link" onClick={() => { setSelectedObservation(id); setActiveTab('observe'); }}>查看证据</Button> : '—' },
|
||||||
|
]} /></Card> },
|
||||||
|
{ key: 'candidates', label: `候选版本 ${task.candidates?.length || 0}`, children: <Card className="surface-card" styles={{ body: { padding: 0 } }}><Table<Candidate> rowKey="id" dataSource={task.candidates || []} pagination={false} locale={{ emptyText: '完成链路操作后,可生成第一个候选版本。' }} columns={[
|
||||||
|
{ title: '名称', dataIndex: 'name', render: (name: string, candidate) => <Button type="link" className="table-link" onClick={() => navigate(`/candidates/${candidate.id}`)}>{name}</Button> }, { title: '版本', dataIndex: 'version', width: 80, render: (version: string | number) => `v${version}` }, { title: '状态', dataIndex: 'status', width: 140, render: (status: string) => <StatusTag status={status} /> }, { title: '创建时间', dataIndex: 'created_at', width: 180, render: (value: string) => formatTime(value) },
|
||||||
|
]} /></Card> },
|
||||||
|
{ key: 'validations', label: `验证记录 ${task.validations?.length || 0}`, children: <Card className="surface-card" styles={{ body: { padding: 0 } }}><ValidationTable items={task.validations || []} onSelect={setRecord} /></Card> },
|
||||||
|
{ key: 'parameters', label: '任务参数', children: <Card className="surface-card"><JsonView value={task.parameters} /><Typography.Paragraph type="secondary">候选固化时保存参数快照。试跑可传入不同参数,原候选内容保持不变。</Typography.Paragraph></Card> },
|
||||||
|
]} />
|
||||||
|
<Modal title="确认官方页面登录已完成" open={readyOpen} onCancel={() => setReadyOpen(false)} onOk={() => void markReady()} okText="记录人工确认" confirmLoading={busy === 'ready'}>
|
||||||
|
<Alert type="info" showIcon message="这是一条人工确认记录" description="请先在浏览器内完成平台登录,并确认当前账号。这里不代收验证码,也不会将你的声明标记为自动身份核验通过。" />
|
||||||
|
<Form layout="vertical" className="modal-form"><Form.Item label="本次确认的公开账号标识或昵称" required><Input value={identity} onChange={event => setIdentity(event.target.value)} placeholder="填写公开标识,避免手机号等登录信息" maxLength={120} autoComplete="off" /></Form.Item></Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal title="生成不可变候选版本" open={candidateOpen} onCancel={() => setCandidateOpen(false)} onOk={() => void createCandidate()} okText="生成候选" confirmLoading={busy === 'candidate'}>
|
||||||
|
<Typography.Paragraph>将当前 {steps.length} 个步骤、参数和页面证据保存为新版本,后续修改不覆盖该版本。</Typography.Paragraph><Form layout="vertical"><Form.Item label="能力名称"><Input value={candidateName} onChange={event => setCandidateName(event.target.value)} maxLength={120} /></Form.Item></Form>
|
||||||
|
</Modal>
|
||||||
|
<Drawer title="验证记录详情" open={Boolean(record)} onClose={() => setRecord(undefined)} width={800}>{record && <ValidationDetails record={record} />}</Drawer>
|
||||||
|
</Page>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Local task creation adapter used by the migrated A2A learning campaign page.
|
||||||
|
import { App, Form, Input, Modal, Select, Typography } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { api, errorText } from '../api';
|
||||||
|
import { parseObject, platformLabels } from '../components/common';
|
||||||
|
import type { LearningTask, Platform } from '../types';
|
||||||
|
|
||||||
|
const templates: Record<Platform, { entry_url: string; goal: string; name: string }> = {
|
||||||
|
xiaohongshu: { entry_url: 'https://www.xiaohongshu.com/explore', goal: '搜索笔记 → 读取详情 → 读取评论,整理定位与分支', name: '小红书搜索与评论读取' },
|
||||||
|
douyin: { entry_url: 'https://www.douyin.com/', goal: '搜索视频 → 作者主页 → 评论读取', name: '抖音搜索与评论读取' },
|
||||||
|
demo: { entry_url: `${location.origin}/api/learning/demo-page`, goal: '搜索本机样本并读取结果,验证学习与试跑流程', name: '本机搜索链路' },
|
||||||
|
other: { entry_url: '', goal: '', name: '' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateTaskModal({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (task: LearningTask) => void }) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const platform = Form.useWatch('platform', form) as Platform | undefined;
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const parameters = parseObject(values.parameters, '任务参数');
|
||||||
|
setBusy(true);
|
||||||
|
const task = await api.post<LearningTask>('/learning/tasks', { ...values, parameters });
|
||||||
|
form.resetFields(); onCreated(task); void message.success('学习任务已创建');
|
||||||
|
} catch (error) { if (!(error && typeof error === 'object' && 'errorFields' in error)) void message.error(errorText(error)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
return <Modal title="新建学习任务" open={open} onCancel={onClose} onOk={() => void submit()} okText="创建任务" confirmLoading={busy} width={700} destroyOnHidden>
|
||||||
|
<Form form={form} layout="vertical" initialValues={{ platform: 'xiaohongshu', ...templates.xiaohongshu, account_key: '', parameters: '{\n "keyword": ""\n}' }}>
|
||||||
|
<div className="form-grid"><Form.Item label="目标平台" name="platform" rules={[{ required: true }]}><Select options={Object.entries(platformLabels).map(([value, label]) => ({ value, label }))} onChange={(value: Platform) => form.setFieldsValue(templates[value])} /></Form.Item><Form.Item label="本地账号引用" name="account_key" extra="用于固定会话资料,不填写手机号或密码。" rules={[{ required: true, message: '请为这份会话资料设置一个本地引用' }, { pattern: /^[a-zA-Z0-9_-]{2,80}$/, message: '使用 2–80 位字母、数字、下划线或短横线' }]}><Input placeholder="例如 xhs-main" autoComplete="off" /></Form.Item></div>
|
||||||
|
<Form.Item label="任务名称" name="name" rules={[{ required: true, message: '请填写任务名称' }]}><Input maxLength={120} /></Form.Item>
|
||||||
|
<Form.Item label="页面入口" name="entry_url" rules={[{ required: true, message: '请填写页面入口' }, { type: 'url', message: '请填写完整的 http 或 https 地址' }]}><Input placeholder="https://" /></Form.Item>
|
||||||
|
<Form.Item label="本次学习目标" name="goal" rules={[{ required: true, message: '请限定本次需要学习的操作' }]}><Input.TextArea rows={2} maxLength={500} /></Form.Item>
|
||||||
|
<Form.Item label="任务参数" name="parameters" extra="将后续需要变化的值定义为参数,例如 keyword。" rules={[{ required: true }]}><Input.TextArea className="json-input" rows={4} spellCheck={false} /></Form.Item>
|
||||||
|
<Typography.Paragraph className="form-note" type="secondary">{platform === 'demo' ? '本机样本用于验证工具链。示例结果不能代替真实平台验收。' : '任务创建后,由你在打开的官方页面自行完成登录。平台模板只预填学习目标,尚未提供已验证链路。'}</Typography.Paragraph>
|
||||||
|
</Form>
|
||||||
|
</Modal>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Migrated from A2A console/pages/TestsPage.tsx: fixed-version test list,
|
||||||
|
// capability selection drawer and per-run detail navigation. Unsupported host/SSE/replay controls are trimmed.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Drawer, Select } from 'antd';
|
||||||
|
import { ChevronRight, Plus, RefreshCw } from 'lucide-react';
|
||||||
|
import { formatTime, StatusTag, useResource } from '../components/common';
|
||||||
|
import { PageControls, LIST_PAGE_SIZE } from '../components/PageControls';
|
||||||
|
import { EmptyState, ResourceState } from '../components/States';
|
||||||
|
import { TestRunHistory } from '../components/TestRunHistory';
|
||||||
|
import { ValidationDetails } from './ValidationsPage';
|
||||||
|
import type { Candidate, PageResult, Validation } from '../types';
|
||||||
|
|
||||||
|
export function TestsPage({ navigate }: { navigate: (path: string) => void }) {
|
||||||
|
const resource = useResource<PageResult<Validation>>('/learning/validations');
|
||||||
|
const capabilities = useResource<PageResult<Candidate>>('/learning/candidates');
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [selectedCandidate, setSelectedCandidate] = useState<string>();
|
||||||
|
const [selected, setSelected] = useState<Validation>();
|
||||||
|
const [kind, setKind] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const all = (resource.data?.items || []).filter(item => (!kind || item.kind === kind) && (!status || item.status === status));
|
||||||
|
const items = all.slice(offset, offset + LIST_PAGE_SIZE);
|
||||||
|
const name = (id: string) => capabilities.data?.items.find(item => item.id === id)?.name || id;
|
||||||
|
const version = (id: string) => capabilities.data?.items.find(item => item.id === id)?.version;
|
||||||
|
return <div className="page-list"><div className="page-heading"><div><h1>脚本测试</h1><p className="subtitle">{resource.data ? `${all.length} 条实际记录` : '固定版本测试与来源结果核对'}</p></div><div className="heading-action button-row"><Button className="quiet-button" icon={<RefreshCw size={16} />} loading={resource.loading} onClick={() => { void resource.reload(); void capabilities.reload(); }}>刷新</Button><Button type="primary" icon={<Plus size={16} />} onClick={() => { void capabilities.reload(); setPickerOpen(true); }}>新建测试</Button></div></div>
|
||||||
|
<div className="filter-bar"><Select aria-label="筛选验证类型" value={kind} onChange={value => { setKind(value); setOffset(0); }} style={{ minWidth: 144 }} options={[{ value: '', label: '全部类型' }, { value: 'static', label: '静态校验' }, { value: 'browser', label: '浏览器试跑' }]} /><Select aria-label="筛选验证结论" value={status} onChange={value => { setStatus(value); setOffset(0); }} style={{ minWidth: 144 }} options={[{ value: '', label: '全部结论' }, { value: 'passed', label: '通过' }, { value: 'failed', label: '失败' }]} /></div>
|
||||||
|
<div className="table-fill"><ResourceState loading={resource.loading} error={resource.error || null} onRetry={() => void resource.reload()} />{resource.data && !resource.loading && !resource.error && <>{items.length ? <div className="table-wrap table-scroll" tabIndex={0} aria-label="脚本测试列表"><table className="plain-table"><thead><tr><th>能力 / 样本</th><th>固定版本</th><th>验证类型</th><th>来源结果</th><th>实测环境</th><th>创建时间</th><th>操作</th></tr></thead><tbody>{items.map(run => <tr key={run.id}><td>{name(run.candidate_id)}<p className="muted small">{run.sample_name || '静态校验'}</p></td><td>{version(run.candidate_id) === undefined ? '—' : `v${version(run.candidate_id)}`}</td><td>{run.kind === 'static' ? '静态校验' : '浏览器试跑'}</td><td><StatusTag status={run.status} /></td><td>{run.kind === 'static' ? '程序校验' : String(run.environment?.os || run.environment?.platform || '未记录')}</td><td>{formatTime(run.created_at)}</td><td><button className="link-button" data-testid="validation-detail" onClick={() => setSelected(run)}>查看执行</button></td></tr>)}</tbody></table></div> : <EmptyState title="还没有测试记录" description="选择能力的固定版本,配置参数样本并核对实际结果。" />}
|
||||||
|
<div className="list-footer"><PageControls offset={offset} total={all.length} count={items.length} loading={resource.loading} onChange={setOffset} /></div></>}
|
||||||
|
</div>
|
||||||
|
<Drawer title="选择能力版本" open={pickerOpen} onClose={() => setPickerOpen(false)} width={480} destroyOnHidden><ResourceState loading={capabilities.loading} error={capabilities.error || null} onRetry={() => void capabilities.reload()} /><label className="field-label" htmlFor="test-capability">选择测试能力</label><Select id="test-capability" placeholder="选择固定候选版本" value={selectedCandidate} onChange={setSelectedCandidate} style={{ width: '100%' }} options={(capabilities.data?.items || []).map(item => ({ value: item.id, label: `${item.name} · v${item.version}` }))} />{selectedCandidate && <><p className="muted small evidence-source">进入能力详情后选择“配置测试”,填入本次参数与核对结果。</p><Button type="primary" icon={<ChevronRight size={16} />} onClick={() => { setPickerOpen(false); navigate(`/candidates/${selectedCandidate}`); }}>配置测试</Button></>}</Drawer>
|
||||||
|
<Drawer title="验证记录详情" open={Boolean(selected)} onClose={() => setSelected(undefined)} width={880} destroyOnHidden>{selected && <><div className="record-actions"><TestRunHistory candidateId={selected.candidate_id} currentRunId={selected.id} onSelect={setSelected} /><Button className="quiet-button" onClick={() => { setSelected(undefined); navigate(`/candidates/${selected.candidate_id}`); }}>查看固定能力版本</Button></div><ValidationDetails record={selected} /></>}</Drawer>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Button, Card, Descriptions, Drawer, Image, Select, Space, Table, Tag, Typography } from 'antd';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { LoadError, Page, StatusTag, JsonView, formatTime, useResource } from '../components/common';
|
||||||
|
import type { PageResult, Validation } from '../types';
|
||||||
|
|
||||||
|
export function ValidationDetails({ record }: { record: Validation }) {
|
||||||
|
return <div className="detail-stack" data-testid="validation-record">
|
||||||
|
<Descriptions bordered size="small" column={2} items={[
|
||||||
|
{ key: 'id', label: '记录标识', children: <Typography.Text copyable>{record.id}</Typography.Text>, span: 2 },
|
||||||
|
{ key: 'kind', label: '验证类型', children: record.kind === 'static' ? '固定规则校验' : '真实浏览器试跑' },
|
||||||
|
{ key: 'status', label: '结论', children: <StatusTag status={record.status} /> },
|
||||||
|
{ key: 'time', label: '记录时间', children: formatTime(record.created_at) },
|
||||||
|
{ key: 'sample', label: '样本名称', children: record.sample_name || '—' },
|
||||||
|
{ key: 'duration', label: '耗时', children: record.duration_ms === undefined ? '未记录' : `${record.duration_ms} ms` },
|
||||||
|
{ key: 'model', label: '模型调用', children: record.model_calls === undefined ? '未记录' : `${record.model_calls} 次` },
|
||||||
|
]} />
|
||||||
|
{record.verification_basis && <section><h4>验证依据</h4><p className="muted">{record.verification_basis === 'synthetic_browser_fixture' ? '本机浏览器样本,用于验证基础链路' : '真实浏览器执行与固定断言核对'}</p></section>}
|
||||||
|
{Boolean(record.assertion_checks?.length) && <section><h4>断言核对</h4><table className="plain-table"><thead><tr><th>检查项</th><th>结论</th><th>说明</th></tr></thead><tbody>{record.assertion_checks!.map((check, index) => <tr key={index}><td>{check.name}</td><td><StatusTag status={check.passed ? 'passed' : 'failed'} /></td><td>{check.message}</td></tr>)}</tbody></table></section>}
|
||||||
|
{record.independent_readback && <section><h4>独立来源读回</h4><p className="muted">{record.independent_readback.message}</p></section>}
|
||||||
|
{Boolean(record.errors?.length) && <section><h4>未通过原因</h4><ul className="error-list">{record.errors!.map((error, i) => <li key={i}>{typeof error === 'string' ? error : JSON.stringify(error)}</li>)}</ul></section>}
|
||||||
|
<section><h4>运行环境</h4><JsonView value={record.environment} /></section>
|
||||||
|
<section><h4>参数快照</h4><JsonView value={record.parameters} /></section>
|
||||||
|
<section><h4>预期输出</h4><JsonView value={record.expected_outputs} /></section>
|
||||||
|
<section><h4>实际输出</h4><JsonView value={record.actual_outputs} /></section>
|
||||||
|
{Boolean(record.evidence?.length) && <section><h4>关联证据</h4><div className="evidence-grid">{record.evidence!.map((item, i) => <div className="evidence-item" key={i}>
|
||||||
|
{typeof item.screenshot_url === 'string' && <Image src={item.screenshot_url} alt={`验证证据 ${i + 1}`} width="100%" />}
|
||||||
|
{typeof item.url === 'string' && <Typography.Paragraph className="evidence-url" ellipsis={{ rows: 2 }}>{item.url}</Typography.Paragraph>}
|
||||||
|
{!item.screenshot_url && <JsonView value={item} compact />}
|
||||||
|
</div>)}</div></section>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValidationTable({ items, loading, onSelect }: { items: Validation[]; loading?: boolean; onSelect: (item: Validation) => void }) {
|
||||||
|
return <Table<Validation> rowKey="id" loading={loading} dataSource={items} size="middle" pagination={{ pageSize: 10, showSizeChanger: false }} scroll={{ x: 760 }}
|
||||||
|
locale={{ emptyText: '还没有验证记录。校验或试跑后,每次结论都会独立保存。' }}
|
||||||
|
columns={[
|
||||||
|
{ title: '验证样本', dataIndex: 'sample_name', key: 'sample', render: (_: unknown, row) => <Button type="link" className="table-link" onClick={() => onSelect(row)}>{row.sample_name || (row.kind === 'static' ? '固定规则校验' : '浏览器参数样本')}</Button> },
|
||||||
|
{ title: '类型', dataIndex: 'kind', width: 130, render: (kind: string) => kind === 'static' ? '固定规则' : '浏览器试跑' },
|
||||||
|
{ title: '结论', dataIndex: 'status', width: 100, render: (status: string) => <StatusTag status={status} /> },
|
||||||
|
{ title: '实测环境', key: 'environment', width: 140, render: (_: unknown, row) => row.kind === 'static' ? '程序校验' : String(row.environment?.os || row.environment?.platform || '未记录') },
|
||||||
|
{ title: '记录时间', dataIndex: 'created_at', width: 180, render: (value: string) => formatTime(value) },
|
||||||
|
{ title: '详情', key: 'detail', width: 75, render: (_: unknown, row) => <Button size="small" onClick={() => onSelect(row)}>查看</Button> },
|
||||||
|
]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValidationsPage() {
|
||||||
|
const resource = useResource<PageResult<Validation>>('/learning/validations');
|
||||||
|
const [kind, setKind] = useState('all');
|
||||||
|
const [status, setStatus] = useState('all');
|
||||||
|
const [selected, setSelected] = useState<Validation>();
|
||||||
|
const items = useMemo(() => (resource.data?.items || []).filter(row => (kind === 'all' || row.kind === kind) && (status === 'all' || row.status === status)), [resource.data, kind, status]);
|
||||||
|
return <Page title="验证记录" description="校验、试跑与核对分别留档,重跑保留历史结论。" actions={<Button onClick={() => void resource.reload()} loading={resource.loading}>刷新</Button>}>
|
||||||
|
<LoadError error={resource.error} reload={resource.reload} />
|
||||||
|
<Card className="surface-card" styles={{ body: { padding: 0 } }}>
|
||||||
|
<div className="table-toolbar"><Space wrap><Select aria-label="筛选验证类型" value={kind} onChange={setKind} options={[{ value: 'all', label: '全部类型' }, { value: 'static', label: '固定规则校验' }, { value: 'browser', label: '浏览器试跑' }]} /><Select aria-label="筛选验证结论" value={status} onChange={setStatus} options={[{ value: 'all', label: '全部结论' }, { value: 'passed', label: '通过' }, { value: 'failed', label: '失败' }]} /></Space><Tag>{items.length} 条记录</Tag></div>
|
||||||
|
<ValidationTable items={items} loading={resource.loading} onSelect={setSelected} />
|
||||||
|
</Card>
|
||||||
|
<Drawer title="验证记录详情" width={800} open={Boolean(selected)} onClose={() => setSelected(undefined)}>{selected && <ValidationDetails record={selected} />}</Drawer>
|
||||||
|
</Page>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--page-bg: #f5f7f8;
|
||||||
|
--surface: #fff;
|
||||||
|
--soft: #f3f5f6;
|
||||||
|
--border: #e3e8eb;
|
||||||
|
--text: #202c32;
|
||||||
|
--muted: #6a777f;
|
||||||
|
--accent: #14695e;
|
||||||
|
--accent-soft: #eaf4f0;
|
||||||
|
--danger: #b43f3f;
|
||||||
|
--warning: #a2782d;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page-bg: #151a1f;
|
||||||
|
--surface: #1d252c;
|
||||||
|
--soft: #252f37;
|
||||||
|
--border: #35424c;
|
||||||
|
--text: #e8edf0;
|
||||||
|
--muted: #a6b4bd;
|
||||||
|
--accent: #7cc6b6;
|
||||||
|
--accent-soft: #253e38;
|
||||||
|
--danger: #ed8b8b;
|
||||||
|
--warning: #dbb671;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||||
|
body { color: var(--text); background: var(--page-bg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
||||||
|
button, input, textarea { font: inherit; }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
.workbench-layout { min-height: 100vh; }
|
||||||
|
.workbench-sider { position: sticky !important; top: 0; height: 100vh; border-right: 1px solid var(--border); background: var(--surface) !important; z-index: 5; }
|
||||||
|
.workbench-sider .ant-layout-sider-children { display: flex; flex-direction: column; }
|
||||||
|
.workbench-sider .ant-menu { flex: 1; border-inline-end: 0 !important; background: transparent !important; padding-top: 9px; }
|
||||||
|
.workbench-sider .ant-layout-sider-trigger { border-top: 1px solid var(--border); background: var(--surface) !important; color: var(--muted) !important; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 11px; height: 90px; padding: 21px 23px; color: var(--text); font-weight: 650; font-size: 19px; letter-spacing: -.3px; text-decoration: none; white-space: nowrap; }
|
||||||
|
.brand:hover { color: var(--text); }
|
||||||
|
.brand small { display: block; font-size: 10px; color: var(--muted); font-weight: 400; letter-spacing: 2px; margin-top: 1px; }
|
||||||
|
.brand-mark { display: inline-flex; width: 30px; height: 32px; align-items: center; justify-content: center; flex-shrink: 0; border-radius: 7px; color: #fff; background: #14695e; font-size: 19px; font-weight: 650; }
|
||||||
|
.ant-layout-sider-collapsed .brand { padding-inline: 17px; }
|
||||||
|
.sider-foot { min-height: 85px; padding: 18px 24px 50px; font-size: 11px; color: var(--muted); white-space: nowrap; }
|
||||||
|
.sider-foot small { display: block; padding-top: 5px; font-size: 10px; }
|
||||||
|
.local-dot { display: inline-block; width: 5px; height: 5px; background: var(--accent); border-radius: 50%; margin: 0 7px 2px 0; }
|
||||||
|
.ant-layout-sider-collapsed .sider-foot { padding-inline: 20px; }
|
||||||
|
.main-layout { min-width: 0; background: var(--page-bg) !important; }
|
||||||
|
.workbench-header { background: var(--surface) !important; border-bottom: 1px solid var(--border); height: 64px; padding: 0 30px !important; display: flex; justify-content: space-between; align-items: center; line-height: 1.4 !important; gap: 18px; }
|
||||||
|
.header-location { display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--muted); }
|
||||||
|
.header-location b { color: var(--text); font-weight: 500; }
|
||||||
|
.breadcrumb-separator { opacity: .5; }
|
||||||
|
.operator-name { font-size: 12px; }
|
||||||
|
.workbench-content { padding: 28px 30px 0; }
|
||||||
|
.workbench-page { width: 100%; max-width: 1560px; margin-inline: auto; }
|
||||||
|
.page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: center; margin-bottom: 23px; }
|
||||||
|
.page-heading h3 { margin: 0 0 5px !important; font-size: 23px !important; font-weight: 600 !important; }
|
||||||
|
.page-heading .ant-typography { margin-bottom: 0; }
|
||||||
|
.page-heading p { font-size: 12px; max-width: 740px; }
|
||||||
|
.surface-card { border-color: var(--border) !important; box-shadow: none !important; }
|
||||||
|
.surface-card .ant-card-head { min-height: 55px; padding-inline: 22px; border-bottom-color: var(--border); }
|
||||||
|
.compact-card .ant-card-body { padding: 18px 22px; }
|
||||||
|
.stats-row { margin-bottom: 22px; }
|
||||||
|
.stat-card { box-shadow: none !important; border-color: var(--border) !important; }
|
||||||
|
.stat-card .ant-card-body { padding: 20px 22px; }
|
||||||
|
.stat-card .ant-statistic-title { font-size: 12px; margin-bottom: 7px; color: var(--muted); }
|
||||||
|
.stat-card .ant-statistic-content { font-size: 29px; font-weight: 550; line-height: 1.3; }
|
||||||
|
.section-heading { display: flex; justify-content: space-between; gap: 20px; align-items: start; margin-bottom: 27px; }
|
||||||
|
.section-heading h5 { margin: 0 0 5px !important; font-size: 15px; }
|
||||||
|
.section-heading .ant-typography-secondary { font-size: 12px; }
|
||||||
|
.workflow-card { margin-bottom: 22px; }
|
||||||
|
.workflow-card .ant-card-body { padding: 22px 25px 27px; }
|
||||||
|
.workflow-card .ant-steps-item-description { font-size: 11px; }
|
||||||
|
.recent-tasks .ant-table-wrapper { padding-top: 0; }
|
||||||
|
.table-toolbar { padding: 18px 22px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.table-link { padding: 0 !important; height: auto !important; text-align: left; font-weight: 500; white-space: normal; }
|
||||||
|
.table-secondary { color: var(--muted); font-size: 11px; margin-top: 4px; max-width: 460px; }
|
||||||
|
.ellipsis { text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
|
||||||
|
.ant-table-wrapper .ant-table-thead > tr > th { font-size: 12px; font-weight: 500; background: var(--soft) !important; color: var(--muted); }
|
||||||
|
.ant-table-wrapper .ant-table-cell { padding-inline: 20px !important; }
|
||||||
|
.ant-table-wrapper .ant-table-tbody > tr > td { border-bottom-color: var(--border); }
|
||||||
|
.ant-table-wrapper .ant-table-pagination { padding-inline: 20px; }
|
||||||
|
.overview-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-top: 24px; }
|
||||||
|
.overview-bottom section { padding: 15px 2px 8px; border-top: 1px solid var(--border); }
|
||||||
|
.overview-bottom h3 { margin: 0 0 6px; font-size: 12px; font-weight: 600; }
|
||||||
|
.overview-bottom p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.9; }
|
||||||
|
.workbench-footer { background: transparent !important; display: flex; align-items: center; justify-content: space-between; padding: 25px 32px 22px !important; color: var(--muted) !important; font-size: 10px; }
|
||||||
|
.load-error { margin-bottom: 18px; }
|
||||||
|
.run-progress { display: flex; gap: 9px; align-items: center; padding: 11px 0 20px; color: var(--accent); font-size: 12px; }
|
||||||
|
.activity-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; background: currentColor; animation: pulse 1.4s infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: .45; } 50% { opacity: 1; } }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
.form-note { font-size: 11px; line-height: 1.9; margin-top: 16px; margin-bottom: 0 !important; }
|
||||||
|
.json-input, .mono, .digest, .selector, .json-view { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Microsoft YaHei", monospace !important; }
|
||||||
|
.json-input { font-size: 12px !important; line-height: 1.8 !important; }
|
||||||
|
.selector { color: var(--muted); word-break: break-all; font-size: 11px; }
|
||||||
|
.digest { font-size: 11px; word-break: break-all; }
|
||||||
|
.json-view { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; margin: 0; background: var(--soft); border: 1px solid var(--border); padding: 14px 16px; border-radius: 6px; font-size: 12px; max-height: 500px; overflow: auto; }
|
||||||
|
.json-view.compact { max-height: 180px; font-size: 11px; }
|
||||||
|
.detail-stack { display: grid; gap: 22px; }
|
||||||
|
.detail-stack h4 { margin: 0 0 9px; font-size: 12px; font-weight: 600; }
|
||||||
|
.error-list { margin: 0; padding-left: 20px; color: var(--danger); font-size: 12px; line-height: 1.9; }
|
||||||
|
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.evidence-item { border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
|
||||||
|
.evidence-item .evidence-url { padding: 5px 9px; }
|
||||||
|
.evidence-url { font-size: 11px; word-break: break-all; color: var(--muted); margin: 5px 0 !important; }
|
||||||
|
.candidate-actions { display: flex; justify-content: space-between; align-items: center; gap: 15px; padding: 16px 0; border-block: 1px solid var(--border); }
|
||||||
|
.candidate-actions > .ant-typography { font-size: 11px; max-width: 210px; }
|
||||||
|
.success-text { color: var(--accent); font-size: 19px; }
|
||||||
|
.warning-text { color: var(--warning); font-size: 19px; }
|
||||||
|
.task-summary { display: flex; align-items: center; justify-content: space-between; margin: -7px 0 20px; gap: 15px; }
|
||||||
|
.task-summary .ant-typography { font-size: 11px; }
|
||||||
|
.task-id { color: var(--muted); }
|
||||||
|
.entry-url { max-width: 100%; font-size: 12px; }
|
||||||
|
.phase-strip { padding: 25px 3px 16px; }
|
||||||
|
.phase-strip .ant-steps-item-description { font-size: 11px; }
|
||||||
|
.session-controls { display: flex; justify-content: space-between; gap: 25px; align-items: start; }
|
||||||
|
.session-controls > .ant-typography { font-size: 11px; max-width: 430px; margin: 0; line-height: 1.9; }
|
||||||
|
.capture-controls { margin-top: 19px; display: flex; gap: 10px; }
|
||||||
|
.capture-controls input { max-width: 580px; }
|
||||||
|
.observation-layout { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(275px, 1fr); gap: 22px; align-items: start; }
|
||||||
|
.observation-meta { padding-bottom: 17px; }
|
||||||
|
.observation-meta > .ant-typography:last-child { font-size: 11px; }
|
||||||
|
.screenshot-frame { width: 100%; max-height: 420px; overflow: auto; border: 1px solid var(--border); border-radius: 5px; background: var(--soft); }
|
||||||
|
.screenshot-frame .ant-image { display: block; }
|
||||||
|
.screenshot-frame img { display: block; }
|
||||||
|
.text-observation { margin-top: 18px; font-size: 12px; }
|
||||||
|
.text-observation summary { cursor: pointer; color: var(--accent); }
|
||||||
|
.text-observation pre { max-height: 260px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; color: var(--muted); line-height: 1.8; padding-top: 8px; }
|
||||||
|
.selected-element > td { background: var(--accent-soft) !important; }
|
||||||
|
.modal-form { margin-top: 20px; }
|
||||||
|
.startup-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 22px; min-height: 100vh; padding: 40px; background: var(--page-bg); }
|
||||||
|
.startup-screen > .ant-alert { max-width: 600px; }
|
||||||
|
.global-error { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 1100; width: min(600px, 90vw); }
|
||||||
|
.auth-layout { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(480px, 1fr); min-height: 100vh; background: var(--surface); }
|
||||||
|
.auth-intro { padding: 40px 8.5vw 30px 7vw; background: var(--page-bg); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
|
||||||
|
.auth-brand { padding: 0; height: auto; }
|
||||||
|
.auth-copy { flex: 1; display: flex; flex-direction: column; justify-content: center; padding-block: 55px; max-width: 520px; }
|
||||||
|
.eyebrow { font-size: 10px; letter-spacing: .16em; color: var(--accent); font-weight: 600; }
|
||||||
|
.auth-copy h1 { font-size: 34px; line-height: 1.65; font-weight: 600; letter-spacing: .02em; margin: 19px 0; }
|
||||||
|
.auth-copy > p { color: var(--muted); font-size: 13px; line-height: 2; max-width: 380px; }
|
||||||
|
.auth-points { list-style: none; margin: 25px 0 0; padding: 0; }
|
||||||
|
.auth-points li { display: flex; align-items: start; gap: 19px; margin-block: 24px; font-size: 13px; }
|
||||||
|
.auth-points li > span { color: var(--accent); font-size: 10px; font-weight: 600; padding-top: 4px; }
|
||||||
|
.auth-points small { display: block; color: var(--muted); font-size: 11px; padding-top: 5px; }
|
||||||
|
.auth-footer { color: var(--muted); font-size: 10px; }
|
||||||
|
.auth-form-side { display: flex; align-items: center; justify-content: center; padding: 50px; }
|
||||||
|
.auth-form-wrap { max-width: 390px; width: 100%; }
|
||||||
|
.auth-form-wrap > .ant-tag { color: var(--accent); background: var(--accent-soft); margin: 0 0 11px; padding-inline: 8px; font-size: 10px; }
|
||||||
|
.auth-form-wrap h2 { font-size: 25px !important; margin: 0 0 8px !important; font-weight: 600 !important; }
|
||||||
|
.auth-form-wrap > .ant-typography-secondary { font-size: 12px; line-height: 1.8; margin-bottom: 30px; }
|
||||||
|
.auth-form-wrap .ant-form-item { margin-bottom: 22px; }
|
||||||
|
.auth-form-wrap .ant-form-item-label label { font-size: 12px; }
|
||||||
|
.auth-form-wrap .ant-form-item-explain { font-size: 11px; }
|
||||||
|
.auth-form-wrap .ant-btn-primary { margin-top: 6px; height: 43px; font-size: 13px; }
|
||||||
|
.auth-note { font-size: 11px !important; line-height: 1.9 !important; margin: 22px 0 0 !important; color: var(--muted); }
|
||||||
|
@media(min-width:1700px) { .workbench-content { padding: 34px 42px 0; } .workbench-header { padding-inline: 42px !important; } }
|
||||||
|
@media(max-width:1200px) { .observation-layout { grid-template-columns: minmax(0, 1.4fr) minmax(260px, 1fr); gap: 16px; } .session-controls { display: block; } .session-controls > .ant-typography { margin-top: 13px; max-width: none; } .auth-intro { padding-inline: 6vw; } }
|
||||||
|
@media(max-width:900px) { .workbench-content { padding: 22px 20px 0; } .workbench-header { padding-inline: 20px !important; } .page-heading { align-items: start; flex-wrap: wrap; gap: 15px; } .page-heading h3 { font-size: 21px !important; } .observation-layout { grid-template-columns: 1fr; } .section-heading { flex-wrap: wrap; gap: 10px; } .candidate-actions { display: block; } .candidate-actions > .ant-typography { display: block; padding-top: 12px; max-width: none; } .auth-layout { grid-template-columns: minmax(0, .8fr) minmax(420px, 1fr); } .auth-copy h1 { font-size: 27px; } .auth-intro { padding-inline: 5vw; } .auth-form-side { padding: 35px; } }
|
||||||
|
@media(max-width:680px) { .workbench-content { padding: 20px 13px 0; } .workbench-header { height: 58px; padding-inline: 15px !important; } .header-location > span:first-child, .breadcrumb-separator, .operator-name { display: none; } .workbench-header > .ant-space { gap: 7px !important; } .workbench-footer { padding-inline: 15px !important; } .workbench-footer > span:last-child { display: none; } .overview-bottom { grid-template-columns: 1fr; gap: 12px; } .stat-card .ant-card-body { padding: 15px 18px; } .stat-card .ant-statistic-content { font-size: 25px; } .surface-card .ant-card-head { padding-inline: 16px; } .table-toolbar { display: block; padding: 15px; } .table-toolbar > .ant-tag { margin-top: 10px; } .task-summary { display: block; } .task-id { display: block; margin-top: 8px; } .capture-controls { flex-wrap: wrap; } .capture-controls .ant-input { width: 100%; flex-basis: 100%; } .form-grid { grid-template-columns: 1fr; gap: 0; } .observation-card .ant-card-head-wrapper { align-items: start; gap: 10px; } .observation-card .ant-card-extra { max-width: 55%; } .observation-card .ant-select { max-width: 100%; } .evidence-grid { grid-template-columns: 1fr; } .auth-layout { display: block; } .auth-intro { padding: 25px 28px; min-height: auto; } .auth-copy { padding: 30px 0 5px; } .auth-copy h1 { font-size: 26px; margin: 13px 0; } .auth-copy h1 br { display: none; } .auth-copy > p { margin: 0; font-size: 12px; } .auth-points, .auth-footer { display: none; } .auth-form-side { padding: 34px 28px; } .auth-form-wrap { max-width: none; } }
|
||||||
|
@media(prefers-reduced-motion:reduce) { .activity-dot { animation: none; } }
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
export interface AuthStatus {
|
||||||
|
initialized: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
csrf_token?: string;
|
||||||
|
user?: { id?: string; username: string; display_name?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Platform = 'demo' | 'xiaohongshu' | 'douyin' | 'other';
|
||||||
|
export interface BrowserElement {
|
||||||
|
selector: string;
|
||||||
|
tag?: string;
|
||||||
|
label?: string;
|
||||||
|
kind?: string;
|
||||||
|
unique?: boolean;
|
||||||
|
sensitive?: boolean;
|
||||||
|
}
|
||||||
|
export interface Observation {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
elements?: BrowserElement[];
|
||||||
|
screenshot_url?: string;
|
||||||
|
created_at?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
export interface RecordedStep {
|
||||||
|
id?: string;
|
||||||
|
kind: 'fill' | 'click' | 'read';
|
||||||
|
selector: string;
|
||||||
|
parameter?: string;
|
||||||
|
value?: string;
|
||||||
|
output_name?: string;
|
||||||
|
evidence_id?: string;
|
||||||
|
}
|
||||||
|
export interface LearningTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform: Platform;
|
||||||
|
entry_url: string;
|
||||||
|
goal: string;
|
||||||
|
account_key: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
browser_status?: string;
|
||||||
|
browser_readiness?: { ready: boolean; code: 'ready' | 'confirmation_required' | 'session_closed'; message: string; next_action: 'none' | 'confirm_ready' | 'open_browser' };
|
||||||
|
observations?: Observation[];
|
||||||
|
steps?: RecordedStep[];
|
||||||
|
candidates?: Candidate[];
|
||||||
|
validations?: Validation[];
|
||||||
|
}
|
||||||
|
export interface Manifest {
|
||||||
|
schema_version: number;
|
||||||
|
entry_url: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
steps: RecordedStep[];
|
||||||
|
}
|
||||||
|
export interface Candidate {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
name: string;
|
||||||
|
version: number | string;
|
||||||
|
digest: string;
|
||||||
|
status: string;
|
||||||
|
created_at?: string;
|
||||||
|
manifest: Manifest;
|
||||||
|
}
|
||||||
|
export interface Validation {
|
||||||
|
id: string;
|
||||||
|
task_id?: string;
|
||||||
|
candidate_id: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
sample_name?: string;
|
||||||
|
errors?: unknown[];
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
expected_outputs?: Record<string, unknown>;
|
||||||
|
actual_outputs?: Record<string, unknown>;
|
||||||
|
environment?: Record<string, unknown>;
|
||||||
|
evidence?: Array<Record<string, unknown>>;
|
||||||
|
created_at?: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
model_calls?: number;
|
||||||
|
assertion_checks?: Array<{ name: string; passed: boolean; message: string }>;
|
||||||
|
verification_basis?: string;
|
||||||
|
independent_readback?: { status: string; independent_identity: boolean; message: string };
|
||||||
|
}
|
||||||
|
export interface PublishCheck {
|
||||||
|
ready: boolean;
|
||||||
|
checks: Array<{ name: string; passed: boolean; detail?: string }>;
|
||||||
|
}
|
||||||
|
export interface Overview {
|
||||||
|
tasks: number;
|
||||||
|
candidates: number;
|
||||||
|
validations: number;
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
published: number;
|
||||||
|
}
|
||||||
|
export interface PageResult<T> { items: T[]; total?: number }
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: fileURLToPath(new URL('.', import.meta.url)),
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5179,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: { '/api': { target: process.env.ASSISTANT_API_TARGET || 'http://127.0.0.1:8810', changeOrigin: false } },
|
||||||
|
},
|
||||||
|
build: { outDir: 'dist', emptyOutDir: true },
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Local maintainer application; cloud commercial services remain separate."""
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""Independent local operator authentication adapted from the reference session approach."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, Response
|
||||||
|
|
||||||
|
from .db import Store, StoreConflict, identifier, now
|
||||||
|
|
||||||
|
COOKIE = "assistant_maintainer"
|
||||||
|
TTL = 12 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def password_hash(password: str) -> str:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=64)
|
||||||
|
return f"scrypt${salt.hex()}${result.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def password_matches(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
_, salt, expected = stored.split("$")
|
||||||
|
result = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1, dklen=64)
|
||||||
|
return hmac.compare_digest(result.hex(), expected)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def token_hash(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class Auth:
|
||||||
|
def __init__(self, store: Store):
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def bootstrap_admin(self) -> bool:
|
||||||
|
"""Create the requested local account only when no maintainer exists."""
|
||||||
|
with self.store.connect(immediate=True) as conn:
|
||||||
|
if conn.execute("SELECT 1 FROM operators LIMIT 1").fetchone():
|
||||||
|
return False
|
||||||
|
conn.execute("INSERT INTO operators(id,username,password_hash,created_at) VALUES($1,$2,$3,$4)", (identifier("operator"), "admin", password_hash("123456"), now()))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def session(self, request: Request) -> dict | None:
|
||||||
|
raw = request.cookies.get(COOKIE, "")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return self.store.one("SELECT s.*,o.username FROM auth_sessions s LEFT JOIN operators o ON o.id=s.operator_id WHERE token_hash=$1 AND expires_at>$2", (token_hash(raw), int(time.time())))
|
||||||
|
|
||||||
|
def issue(self, request: Request, response: Response, operator_id: str | None = None) -> dict:
|
||||||
|
previous = request.cookies.get(COOKIE)
|
||||||
|
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||||
|
with self.store.connect(immediate=True) as conn:
|
||||||
|
if previous:
|
||||||
|
conn.execute("DELETE FROM auth_sessions WHERE token_hash=$1", (token_hash(previous),))
|
||||||
|
conn.execute("DELETE FROM auth_sessions WHERE expires_at<$1", (int(time.time()),))
|
||||||
|
conn.execute("INSERT INTO auth_sessions(token_hash,operator_id,csrf,expires_at,created_at) VALUES($1,$2,$3,$4,$5)", (token_hash(raw), operator_id, csrf, int(time.time()) + TTL, now()))
|
||||||
|
response.set_cookie(COOKIE, raw, max_age=TTL, httponly=True, samesite="strict", secure=request.url.scheme == "https", path="/")
|
||||||
|
return {"operator_id": operator_id, "csrf": csrf}
|
||||||
|
|
||||||
|
def payload(self, session: dict) -> dict:
|
||||||
|
initialized = bool(self.store.one("SELECT id FROM operators LIMIT 1"))
|
||||||
|
authenticated = bool(session.get("operator_id"))
|
||||||
|
user = None
|
||||||
|
if authenticated:
|
||||||
|
user = self.store.one("SELECT id,username,created_at FROM operators WHERE id=$1", (session["operator_id"],))
|
||||||
|
return {"initialized": initialized, "authenticated": authenticated, "user": user, "csrf_token": session["csrf"], "mode": "local_maintainer"}
|
||||||
|
|
||||||
|
def status(self, request: Request, response: Response) -> dict:
|
||||||
|
return self.payload(self.session(request) or self.issue(request, response))
|
||||||
|
|
||||||
|
def csrf(self, request: Request) -> dict:
|
||||||
|
session = self.session(request)
|
||||||
|
header = request.headers.get("X-CSRF-Token", "")
|
||||||
|
if not session or not header or not hmac.compare_digest(header, session["csrf"]):
|
||||||
|
raise HTTPException(403, "会话校验已失效,请刷新页面后重试")
|
||||||
|
return session
|
||||||
|
|
||||||
|
def operator(self, request: Request, mutate: bool = False) -> str:
|
||||||
|
session = self.csrf(request) if mutate else self.session(request)
|
||||||
|
if not session or not session.get("operator_id"):
|
||||||
|
raise HTTPException(401, "请先登录维护后台")
|
||||||
|
return session["operator_id"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_credentials(username: str, password: str) -> tuple[str, str]:
|
||||||
|
username = username.strip()
|
||||||
|
if not re.fullmatch(r"[\w.-]{3,64}", username):
|
||||||
|
raise HTTPException(422, "维护者用户名需为 3 至 64 位字母、数字、中文、点或下划线")
|
||||||
|
if len(password) < 10 or len(password) > 256:
|
||||||
|
raise HTTPException(422, "维护者密码需为 10 至 256 个字符")
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
def setup(self, request: Request, response: Response, username: str, password: str) -> dict:
|
||||||
|
self.csrf(request)
|
||||||
|
username, password = self.validate_credentials(username, password)
|
||||||
|
encoded = password_hash(password)
|
||||||
|
operator_id = identifier("operator")
|
||||||
|
try:
|
||||||
|
with self.store.connect(immediate=True) as conn:
|
||||||
|
if conn.execute("SELECT 1 FROM operators LIMIT 1").fetchone():
|
||||||
|
raise HTTPException(409, "维护者已初始化,请使用登录入口")
|
||||||
|
conn.execute("INSERT INTO operators(id,username,password_hash,created_at) VALUES($1,$2,$3,$4)", (operator_id, username, encoded, now()))
|
||||||
|
except (sqlite3.IntegrityError, StoreConflict):
|
||||||
|
raise HTTPException(409, "维护者已初始化,请使用登录入口") from None
|
||||||
|
return self.payload(self.issue(request, response, operator_id))
|
||||||
|
|
||||||
|
def login(self, request: Request, response: Response, username: str, password: str) -> dict:
|
||||||
|
self.csrf(request)
|
||||||
|
key = token_hash(username.strip().casefold())
|
||||||
|
current_time = int(time.time())
|
||||||
|
with self.store.connect(immediate=True) as conn:
|
||||||
|
attempt = conn.execute("SELECT * FROM auth_attempts WHERE key=$1", (key,)).fetchone()
|
||||||
|
if attempt and current_time - attempt["window_start"] < 300 and attempt["failures"] >= 5:
|
||||||
|
raise HTTPException(429, "尝试次数较多,请稍后再试")
|
||||||
|
user = conn.execute("SELECT * FROM operators WHERE username=$1", (username.strip(),)).fetchone()
|
||||||
|
# The fixed dummy hash keeps a nonexistent username on the scrypt path.
|
||||||
|
dummy = "scrypt$" + "00" * 16 + "$" + "00" * 64
|
||||||
|
valid = password_matches(password, user["password_hash"] if user else dummy)
|
||||||
|
if not user or not valid:
|
||||||
|
if not attempt or current_time - attempt["window_start"] >= 300:
|
||||||
|
conn.execute("INSERT INTO auth_attempts(key,failures,window_start) VALUES($1,$2,$3) ON CONFLICT(key) DO UPDATE SET failures=excluded.failures,window_start=excluded.window_start", (key, 1, current_time))
|
||||||
|
else:
|
||||||
|
conn.execute("UPDATE auth_attempts SET failures=failures+1 WHERE key=$1", (key,))
|
||||||
|
error = True
|
||||||
|
else:
|
||||||
|
conn.execute("DELETE FROM auth_attempts WHERE key=$1", (key,))
|
||||||
|
operator_id = user["id"]
|
||||||
|
error = False
|
||||||
|
if error:
|
||||||
|
raise HTTPException(401, "用户名或密码不正确")
|
||||||
|
return self.payload(self.issue(request, response, operator_id))
|
||||||
|
|
||||||
|
def logout(self, request: Request, response: Response) -> dict:
|
||||||
|
self.csrf(request)
|
||||||
|
return self.payload(self.issue(request, response))
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
||||||
|
|
||||||
|
|
||||||
|
def identifier(prefix: str) -> str:
|
||||||
|
return f"{prefix}_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def encoded(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
def digest(value: Any) -> str:
|
||||||
|
return hashlib.sha256(encoded(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_versions(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS operators(
|
||||||
|
id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_sessions(
|
||||||
|
token_hash TEXT PRIMARY KEY, operator_id TEXT REFERENCES operators(id), csrf TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_attempts(key TEXT PRIMARY KEY, failures INTEGER NOT NULL, window_start INTEGER NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_tasks(
|
||||||
|
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES operators(id), name TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL, entry_url TEXT NOT NULL, goal TEXT NOT NULL, account_key TEXT NOT NULL,
|
||||||
|
parameters_json TEXT NOT NULL, status TEXT NOT NULL, identity_label TEXT,
|
||||||
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS observations(
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES learning_tasks(id), data_json TEXT NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS observations_task ON observations(task_id,created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_steps(
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES learning_tasks(id), position INTEGER NOT NULL,
|
||||||
|
data_json TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(task_id,position)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS candidates(
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES learning_tasks(id), name TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL, digest TEXT NOT NULL, manifest_json TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(task_id,version)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS validations(
|
||||||
|
id TEXT PRIMARY KEY, candidate_id TEXT NOT NULL REFERENCES candidates(id), task_id TEXT NOT NULL REFERENCES learning_tasks(id),
|
||||||
|
kind TEXT NOT NULL, status TEXT NOT NULL, data_json TEXT NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS validations_candidate ON validations(candidate_id,created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS artifacts(
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES learning_tasks(id), relative_path TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL, bytes INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_candidate_manifest BEFORE UPDATE ON candidates
|
||||||
|
BEGIN SELECT RAISE(ABORT,'candidate versions are immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_validation BEFORE UPDATE ON validations
|
||||||
|
BEGIN SELECT RAISE(ABORT,'validation records are append only'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_candidate_delete BEFORE DELETE ON candidates
|
||||||
|
BEGIN SELECT RAISE(ABORT,'candidate versions are immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_validation_delete BEFORE DELETE ON validations
|
||||||
|
BEGIN SELECT RAISE(ABORT,'validation records are append only'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_observation_update BEFORE UPDATE ON observations
|
||||||
|
BEGIN SELECT RAISE(ABORT,'source observations are append only'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_observation_delete BEFORE DELETE ON observations
|
||||||
|
BEGIN SELECT RAISE(ABORT,'source observations are append only'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_step_update BEFORE UPDATE ON learning_steps
|
||||||
|
BEGIN SELECT RAISE(ABORT,'learned steps are append only'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_step_delete BEFORE DELETE ON learning_steps
|
||||||
|
BEGIN SELECT RAISE(ABORT,'learned steps are append only'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_artifact_update BEFORE UPDATE ON artifacts
|
||||||
|
BEGIN SELECT RAISE(ABORT,'evidence descriptors are immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS immutable_artifact_delete BEFORE DELETE ON artifacts
|
||||||
|
BEGIN SELECT RAISE(ABORT,'evidence descriptors are immutable'); END;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteConnection(sqlite3.Connection):
|
||||||
|
def execute(self, sql: str, parameters=(), /):
|
||||||
|
# SQLite treats native $1 names as named parameters; bind by name rather
|
||||||
|
# than the sequence compatibility path removed in Python 3.14. Query text
|
||||||
|
# remains unchanged, and legacy qmark calls in SQLite-only tools still work.
|
||||||
|
if isinstance(parameters, (tuple, list)) and re.search(r"\$[1-9][0-9]*\b", sql):
|
||||||
|
parameters = {str(index): value for index, value in enumerate(parameters, 1)}
|
||||||
|
return super().execute(sql, parameters)
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
backend = "sqlite"
|
||||||
|
|
||||||
|
def __init__(self, home: Path):
|
||||||
|
self.home = home.resolve()
|
||||||
|
self.home.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path = self.home / "learning.sqlite"
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.execute("INSERT INTO schema_versions(version,applied_at) VALUES(1,$1) ON CONFLICT(version) DO NOTHING", (now(),))
|
||||||
|
conn.execute("INSERT INTO schema_versions(version,applied_at) VALUES(2,$1) ON CONFLICT(version) DO NOTHING", (now(),))
|
||||||
|
|
||||||
|
def order_column(self, alias: str = "") -> str:
|
||||||
|
if alias not in ("", "c", "v"):
|
||||||
|
raise ValueError("unsupported internal table alias")
|
||||||
|
return (alias + "." if alias else "") + ("ordinal" if self.backend == "postgresql" else "rowid")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect(self, immediate: bool = False) -> Iterator[sqlite3.Connection]:
|
||||||
|
conn = sqlite3.connect(self.path, timeout=15, factory=SQLiteConnection)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=15000")
|
||||||
|
try:
|
||||||
|
if immediate:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except BaseException:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def one(self, query: str, args: tuple = ()) -> dict | None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute(query, args).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def rows(self, query: str, args: tuple = ()) -> list[dict]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
return [dict(row) for row in conn.execute(query, args).fetchall()]
|
||||||
|
|
||||||
|
def execute(self, query: str, args: tuple = ()) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(query, args)
|
||||||
|
|
||||||
|
|
||||||
|
class StoreError(Exception):
|
||||||
|
"""Public database diagnostics never include a DSN or a driver exception."""
|
||||||
|
def __init__(self, code: str, message: str, status: int = 503):
|
||||||
|
self.code, self.message, self.status = code, message, status
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class StoreConflict(StoreError):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("database_conflict", "记录冲突或不可变事实禁止修改,请重新读取后核对", 409)
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresStore(Store):
|
||||||
|
backend = "postgresql"
|
||||||
|
|
||||||
|
def __init__(self, home: Path, database_url: str, *, schema: str = "assistant_learning"):
|
||||||
|
if len(schema) > 63 or not re.fullmatch(r"assistant_learning(?:_[a-z0-9_]+)?", schema):
|
||||||
|
raise StoreError("database_schema_invalid", "学习数据库命名空间无效")
|
||||||
|
if not database_url.startswith(("postgresql://", "postgres://")):
|
||||||
|
raise StoreError("database_configuration_invalid", "学习数据库地址需使用 PostgreSQL URL")
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
from psycopg import sql
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
except ImportError:
|
||||||
|
raise StoreError("database_driver_unavailable", "缺少 PostgreSQL 驱动,请安装学习后台依赖") from None
|
||||||
|
self.home = home.resolve()
|
||||||
|
self.home.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path = None
|
||||||
|
self.schema = schema
|
||||||
|
self._database_url = database_url
|
||||||
|
self._driver, self._sql, self._row_factory = psycopg, sql, dict_row
|
||||||
|
self._write_lock = int.from_bytes(hashlib.sha256((schema + ":write").encode()).digest()[:8], "big", signed=True)
|
||||||
|
with self.connect(immediate=True) as conn:
|
||||||
|
conn.execute(sql.SQL("CREATE SCHEMA IF NOT EXISTS {}" ).format(sql.Identifier(schema)))
|
||||||
|
conn.execute(sql.SQL("REVOKE ALL ON SCHEMA {} FROM PUBLIC").format(sql.Identifier(schema)))
|
||||||
|
conn.execute((Path(__file__).with_name("postgres.sql")).read_text(encoding="utf-8"))
|
||||||
|
for table in ("observations", "learning_steps", "candidates", "validations", "artifacts"):
|
||||||
|
conn.execute(sql.SQL("CREATE OR REPLACE TRIGGER immutable_learning_fact BEFORE UPDATE OR DELETE ON {} FOR EACH ROW EXECUTE FUNCTION reject_learning_fact_mutation()").format(sql.Identifier(table)))
|
||||||
|
conn.execute(sql.SQL("CREATE OR REPLACE TRIGGER immutable_learning_truncate BEFORE TRUNCATE ON {} FOR EACH STATEMENT EXECUTE FUNCTION reject_learning_fact_mutation()").format(sql.Identifier(table)))
|
||||||
|
conn.execute("INSERT INTO schema_versions(version,applied_at) VALUES(3,$1) ON CONFLICT(version) DO NOTHING", (now(),))
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect(self, immediate: bool = False) -> Iterator[Any]:
|
||||||
|
connection = None
|
||||||
|
try:
|
||||||
|
connection = self._driver.connect(self._database_url, cursor_factory=self._driver.RawCursor,
|
||||||
|
row_factory=self._row_factory, connect_timeout=5)
|
||||||
|
# This schema is fixed by the host, never supplied by an HTTP caller.
|
||||||
|
# Both drivers bind native $1/$2 parameters; no query text rewriting.
|
||||||
|
connection.execute(self._sql.SQL("SET search_path TO {}, pg_catalog").format(self._sql.Identifier(self.schema)))
|
||||||
|
connection.execute("SET lock_timeout TO '15s'")
|
||||||
|
connection.execute("SET statement_timeout TO '30s'")
|
||||||
|
if immediate:
|
||||||
|
# Same write serialization guarantee as SQLite BEGIN IMMEDIATE.
|
||||||
|
connection.execute("SELECT pg_advisory_xact_lock($1)", (self._write_lock,))
|
||||||
|
yield connection
|
||||||
|
connection.commit()
|
||||||
|
except self._driver.IntegrityError:
|
||||||
|
if connection is not None:
|
||||||
|
self._rollback(connection)
|
||||||
|
raise StoreConflict() from None
|
||||||
|
except self._driver.Error:
|
||||||
|
if connection is not None:
|
||||||
|
self._rollback(connection)
|
||||||
|
raise StoreError("database_unavailable", "学习数据库操作未完成,请检查本机 PostgreSQL 服务并重新读取记录") from None
|
||||||
|
except BaseException:
|
||||||
|
if connection is not None:
|
||||||
|
self._rollback(connection)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def _rollback(self, connection) -> None:
|
||||||
|
try:
|
||||||
|
connection.rollback()
|
||||||
|
except self._driver.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def configured_store(home: Path, database_url: str | None = None, *, schema: str | None = None) -> Store:
|
||||||
|
selected = os.environ.get("ASSISTANT_DATABASE_URL", "") if database_url is None else database_url
|
||||||
|
namespace = os.environ.get("ASSISTANT_DATABASE_SCHEMA", "assistant_learning") if schema is None else schema
|
||||||
|
return PostgresStore(home, selected, schema=namespace) if selected else Store(home)
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qsl, urlsplit
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .db import Store, digest, encoded, identifier, now
|
||||||
|
from .learning_checks import evidence_graph_errors, output_assertion_checks
|
||||||
|
from .worker import WorkerError
|
||||||
|
|
||||||
|
SENSITIVE_KEY = re.compile(r"(^|[_-])(password|passwd|phone|mobile|otp|sms|api_key|access_token|refresh_token)($|[_-])|密码|验证码|手机号", re.I)
|
||||||
|
NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_entry_url(value: str) -> str:
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
|
||||||
|
raise ValueError
|
||||||
|
if any(SENSITIVE_KEY.search(key) for key, _ in parse_qsl(parsed.query)):
|
||||||
|
raise ValueError
|
||||||
|
parsed.port
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(422, "入口需为不含凭据的 http 或 https 页面地址") from None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def check_parameters(value: dict) -> dict:
|
||||||
|
if len(value) > 20:
|
||||||
|
raise HTTPException(422, "首版每条链路最多 20 个参数")
|
||||||
|
for key, item in value.items():
|
||||||
|
if not NAME.fullmatch(key) or SENSITIVE_KEY.search(key):
|
||||||
|
raise HTTPException(422, "参数名需为普通业务字段,不能用于登录凭据")
|
||||||
|
if not isinstance(item, str) or len(item) > 2000:
|
||||||
|
raise HTTPException(422, "首版参数值需为不超过 2000 字符的文本")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def os_name(value: str) -> str:
|
||||||
|
return {"win32": "windows", "windows": "windows", "darwin": "macos", "macos": "macos", "linux": "linux"}.get(value.lower(), value.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def compare_outputs(actual: dict, expected: dict) -> list[str]:
|
||||||
|
return [item["message"] for item in output_assertion_checks(actual, expected) if not item["passed"]]
|
||||||
|
|
||||||
|
|
||||||
|
class LearningService:
|
||||||
|
def __init__(self, store: Store, worker):
|
||||||
|
self.store, self.worker = store, worker
|
||||||
|
self.sessions: dict[str, dict] = {}
|
||||||
|
# Browser contexts are process-owned. Persisted learning evidence survives,
|
||||||
|
# but a new server cannot inherit a predecessor's ready confirmation.
|
||||||
|
self.store.execute("UPDATE learning_tasks SET status='paused',updated_at=$1 WHERE status IN ('awaiting_user','observing')", (now(),))
|
||||||
|
|
||||||
|
def browser_readiness(self, task_id: str) -> dict:
|
||||||
|
status = self.sessions.get(task_id, {}).get("status", "closed")
|
||||||
|
if status == "ready":
|
||||||
|
return {"ready": True, "code": "ready", "message": "本次浏览器会话已由维护者确认,可以观察或试跑", "next_action": "none"}
|
||||||
|
if status == "awaiting_user":
|
||||||
|
return {"ready": False, "code": "confirmation_required", "message": "请在官方页面完成登录,再人工确认当前账号;确认前不采集页面", "next_action": "confirm_ready"}
|
||||||
|
return {"ready": False, "code": "session_closed", "message": "请打开学习浏览器并人工确认登录状态;历史证据和验证记录已保留", "next_action": "open_browser"}
|
||||||
|
|
||||||
|
def pause_active_task(self, task_id: str) -> None:
|
||||||
|
self.store.execute("UPDATE learning_tasks SET status='paused',updated_at=$1 WHERE id=$2 AND status IN ('awaiting_user','observing')", (now(), task_id))
|
||||||
|
|
||||||
|
async def refresh_browser(self, task_id: str, owner_id: str) -> dict:
|
||||||
|
self.task(task_id, owner_id)
|
||||||
|
if task_id in self.sessions:
|
||||||
|
try:
|
||||||
|
state = await self.call(task_id, "status", {})
|
||||||
|
except HTTPException:
|
||||||
|
self.sessions.pop(task_id, None)
|
||||||
|
self.pause_active_task(task_id)
|
||||||
|
else:
|
||||||
|
if state.get("status") == "closed":
|
||||||
|
self.sessions.pop(task_id, None)
|
||||||
|
self.pause_active_task(task_id)
|
||||||
|
else:
|
||||||
|
self.sessions[task_id] = {**state, "status": "ready" if state.get("manual_confirmation") else "awaiting_user"}
|
||||||
|
return self.browser_readiness(task_id)
|
||||||
|
|
||||||
|
async def require_ready(self, task_id: str, owner_id: str) -> None:
|
||||||
|
readiness = await self.refresh_browser(task_id, owner_id)
|
||||||
|
if not readiness["ready"]:
|
||||||
|
code = "browser_confirmation_required" if readiness["code"] == "confirmation_required" else "browser_session_required"
|
||||||
|
raise HTTPException(409, {"code": code, "message": readiness["message"], "next_action": readiness["next_action"]})
|
||||||
|
|
||||||
|
def task(self, task_id: str, owner_id: str) -> dict:
|
||||||
|
row = self.store.one("SELECT * FROM learning_tasks WHERE id=$1 AND owner_id=$2", (task_id, owner_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "学习任务不存在")
|
||||||
|
row["parameters"] = json.loads(row.pop("parameters_json"))
|
||||||
|
row.pop("owner_id", None)
|
||||||
|
row.pop("ordinal", None)
|
||||||
|
row["browser_status"] = self.sessions.get(task_id, {}).get("status", "closed")
|
||||||
|
row["browser_readiness"] = self.browser_readiness(task_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def candidate(self, candidate_id: str, owner_id: str) -> dict:
|
||||||
|
row = self.store.one("SELECT c.* FROM candidates c JOIN learning_tasks t ON t.id=c.task_id WHERE c.id=$1 AND t.owner_id=$2", (candidate_id, owner_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "候选能力不存在")
|
||||||
|
row["manifest"] = json.loads(row.pop("manifest_json"))
|
||||||
|
row.pop("ordinal", None)
|
||||||
|
validation = self.store.one(f"SELECT kind,status FROM validations WHERE candidate_id=$1 ORDER BY {self.store.order_column()} DESC LIMIT 1", (candidate_id,))
|
||||||
|
row["status"] = "validated" if validation and validation["kind"] == "browser" and validation["status"] == "passed" else "checked" if validation and validation["kind"] == "static" and validation["status"] == "passed" else "candidate"
|
||||||
|
return row
|
||||||
|
|
||||||
|
def tasks(self, owner_id: str) -> list[dict]:
|
||||||
|
return [self.task(row["id"], owner_id) for row in self.store.rows(f"SELECT id FROM learning_tasks WHERE owner_id=$1 ORDER BY {self.store.order_column()} DESC", (owner_id,))]
|
||||||
|
|
||||||
|
def create_task(self, owner_id: str, values: dict) -> dict:
|
||||||
|
check_entry_url(values["entry_url"])
|
||||||
|
parameters = check_parameters(values.get("parameters") or {})
|
||||||
|
if not values["name"].strip():
|
||||||
|
raise HTTPException(422, "学习任务名称不能为空")
|
||||||
|
timestamp, task_id = now(), identifier("learn")
|
||||||
|
account_key = values["account_key"].strip()
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_.-]{2,80}", account_key) or re.fullmatch(r"1\d{10}", account_key):
|
||||||
|
raise HTTPException(422, "本地账号标识请使用 2 至 80 位字母数字编号,不使用平台手机号")
|
||||||
|
self.store.execute("INSERT INTO learning_tasks(id,owner_id,name,platform,entry_url,goal,account_key,parameters_json,status,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", (task_id, owner_id, values["name"].strip(), values["platform"], values["entry_url"], values["goal"].strip(), account_key, encoded(parameters), "draft", timestamp, timestamp))
|
||||||
|
return self.task(task_id, owner_id)
|
||||||
|
|
||||||
|
def set_status(self, task_id: str, status: str) -> None:
|
||||||
|
self.store.execute("UPDATE learning_tasks SET status=$1,updated_at=$2 WHERE id=$3", (status, now(), task_id))
|
||||||
|
|
||||||
|
def overview(self, owner_id: str) -> dict:
|
||||||
|
task_ids = "SELECT id FROM learning_tasks WHERE owner_id=$1"
|
||||||
|
def count(table, where="", args=()):
|
||||||
|
scope = "owner_id=$1" if table == "learning_tasks" else f"task_id IN ({task_ids})"
|
||||||
|
return self.store.one(f"SELECT count(*) AS n FROM {table} WHERE {scope}{where}", (owner_id, *args))["n"]
|
||||||
|
return {"tasks": count("learning_tasks"), "candidates": count("candidates"), "validations": count("validations"), "passed": count("validations", " AND status='passed'"), "failed": count("validations", " AND status='failed'"), "published": 0}
|
||||||
|
|
||||||
|
def observations(self, task_id: str) -> list[dict]:
|
||||||
|
return [{"id": row["id"], "task_id": row["task_id"], "created_at": row["created_at"], **json.loads(row["data_json"])} for row in self.store.rows(f"SELECT * FROM observations WHERE task_id=$1 ORDER BY {self.store.order_column()} ASC", (task_id,))]
|
||||||
|
|
||||||
|
def steps(self, task_id: str) -> list[dict]:
|
||||||
|
return [{"id": row["id"], "position": row["position"], "created_at": row["created_at"], **json.loads(row["data_json"])} for row in self.store.rows("SELECT * FROM learning_steps WHERE task_id=$1 ORDER BY position", (task_id,))]
|
||||||
|
|
||||||
|
def validations(self, owner_id: str, candidate_id: str | None = None, task_id: str | None = None) -> list[dict]:
|
||||||
|
query = "SELECT v.* FROM validations v JOIN learning_tasks t ON t.id=v.task_id WHERE t.owner_id=$1"
|
||||||
|
args = [owner_id]
|
||||||
|
if candidate_id:
|
||||||
|
args.append(candidate_id)
|
||||||
|
query += f" AND v.candidate_id=${len(args)}"
|
||||||
|
if task_id:
|
||||||
|
args.append(task_id)
|
||||||
|
query += f" AND v.task_id=${len(args)}"
|
||||||
|
return [{"id": row["id"], "candidate_id": row["candidate_id"], "task_id": row["task_id"], "kind": row["kind"], "status": row["status"], "created_at": row["created_at"], **json.loads(row["data_json"])} for row in self.store.rows(query + f" ORDER BY {self.store.order_column('v')} DESC", tuple(args))]
|
||||||
|
|
||||||
|
def detail(self, task_id: str, owner_id: str) -> dict:
|
||||||
|
task = self.task(task_id, owner_id)
|
||||||
|
task.update(observations=self.observations(task_id), steps=self.steps(task_id), candidates=[self.candidate(row["id"], owner_id) for row in self.store.rows("SELECT id FROM candidates WHERE task_id=$1 ORDER BY version DESC", (task_id,))], validations=self.validations(owner_id, task_id=task_id))
|
||||||
|
return task
|
||||||
|
|
||||||
|
def candidates(self, owner_id: str) -> list[dict]:
|
||||||
|
return [self.candidate(row["id"], owner_id) for row in self.store.rows(f"SELECT c.id FROM candidates c JOIN learning_tasks t ON t.id=c.task_id WHERE t.owner_id=$1 ORDER BY {self.store.order_column('c')} DESC", (owner_id,))]
|
||||||
|
|
||||||
|
async def call(self, task_id: str, method: str, params: dict) -> dict:
|
||||||
|
try:
|
||||||
|
return await self.worker.call(method, {"task_id": task_id, **params})
|
||||||
|
except WorkerError as error:
|
||||||
|
if error.code.lower() in ("worker_closed", "worker_timeout", "browser_closed", "session_not_found", "session_closed"):
|
||||||
|
self.sessions.pop(task_id, None)
|
||||||
|
self.pause_active_task(task_id)
|
||||||
|
raise HTTPException(409, {"message": str(error), "code": error.code}) from None
|
||||||
|
|
||||||
|
async def open(self, task_id: str, owner_id: str) -> dict:
|
||||||
|
task = self.task(task_id, owner_id)
|
||||||
|
scoped_account = hashlib.sha256(f"{owner_id}|{task['platform']}|{task['account_key']}".encode()).hexdigest()
|
||||||
|
result = await self.call(task_id, "open", {"account_key": scoped_account, "entry_url": task["entry_url"]})
|
||||||
|
self.sessions[task_id] = {**result, "status": "awaiting_user"}
|
||||||
|
self.set_status(task_id, "awaiting_user")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def ready(self, task_id: str, owner_id: str, identity_label: str) -> dict:
|
||||||
|
self.task(task_id, owner_id)
|
||||||
|
if task_id not in self.sessions:
|
||||||
|
raise HTTPException(409, {"code": "browser_session_required", "message": self.browser_readiness(task_id)["message"], "next_action": "open_browser"})
|
||||||
|
if re.search(r"(?<!\d)1\d{10}(?!\d)", identity_label):
|
||||||
|
raise HTTPException(422, "请填写账号显示名或本地别名,不填写手机号")
|
||||||
|
result = await self.call(task_id, "ready", {"identity_label": identity_label})
|
||||||
|
self.sessions[task_id] = {**result, "status": "ready"}
|
||||||
|
self.store.execute("UPDATE learning_tasks SET identity_label=$1,status='observing',updated_at=$2 WHERE id=$3", (identity_label, now(), task_id))
|
||||||
|
return {**result, "manual_confirmation": True, "identity_verification": "operator_confirmed"}
|
||||||
|
|
||||||
|
def register_artifact(self, task_id: str, raw_path: str) -> str:
|
||||||
|
target = Path(raw_path).resolve()
|
||||||
|
if not target.is_relative_to(self.store.home) or target.suffix.lower() != ".png" or not target.is_file():
|
||||||
|
raise HTTPException(409, "执行器证据文件无效,未登记到验证记录")
|
||||||
|
content = target.read_bytes()
|
||||||
|
if not content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
raise HTTPException(409, "执行器证据不是有效 PNG")
|
||||||
|
artifact_id = identifier("evidence")
|
||||||
|
self.store.execute("INSERT INTO artifacts(id,task_id,relative_path,sha256,bytes,created_at) VALUES($1,$2,$3,$4,$5,$6)", (artifact_id, task_id, target.relative_to(self.store.home).as_posix(), hashlib.sha256(content).hexdigest(), len(content), now()))
|
||||||
|
return artifact_id
|
||||||
|
|
||||||
|
def save_observation(self, task_id: str, capture: dict, note: str = "", source: str = "learning") -> dict:
|
||||||
|
data = deepcopy(capture)
|
||||||
|
raw_path = data.pop("screenshot_path", None)
|
||||||
|
data["source"], data["note"] = source, note
|
||||||
|
if raw_path:
|
||||||
|
artifact_id = self.register_artifact(task_id, raw_path)
|
||||||
|
data["artifact_id"] = artifact_id
|
||||||
|
data["screenshot_url"] = "/api/learning/artifacts/" + artifact_id
|
||||||
|
data["text"] = str(data.get("text", ""))[:30000]
|
||||||
|
data["elements"] = list(data.get("elements") or [])[:300]
|
||||||
|
observation_id, timestamp = identifier("observation"), now()
|
||||||
|
self.store.execute("INSERT INTO observations(id,task_id,data_json,created_at) VALUES($1,$2,$3,$4)", (observation_id, task_id, encoded(data), timestamp))
|
||||||
|
return {"id": observation_id, "task_id": task_id, "created_at": timestamp, **data}
|
||||||
|
|
||||||
|
async def capture(self, task_id: str, owner_id: str, note: str = "") -> dict:
|
||||||
|
await self.require_ready(task_id, owner_id)
|
||||||
|
capture = await self.call(task_id, "capture", {"note": note})
|
||||||
|
return self.save_observation(task_id, capture, note)
|
||||||
|
|
||||||
|
async def action(self, task_id: str, owner_id: str, values: dict) -> dict:
|
||||||
|
task = self.task(task_id, owner_id)
|
||||||
|
await self.require_ready(task_id, owner_id)
|
||||||
|
observations = self.observations(task_id)
|
||||||
|
if not observations:
|
||||||
|
raise HTTPException(409, "请先采集当前页面,再记录链路动作")
|
||||||
|
if len(self.steps(task_id)) >= 60:
|
||||||
|
raise HTTPException(409, "本次学习已达到 60 步上限,请拆分能力")
|
||||||
|
last = observations[-1]
|
||||||
|
element = next((item for item in last.get("elements", []) if item.get("selector") == values["selector"]), None)
|
||||||
|
if not element or element.get("sensitive"):
|
||||||
|
raise HTTPException(422, "请选择最近页面证据中的非敏感元素")
|
||||||
|
kind, parameter, value = values["kind"], values.get("parameter"), values.get("value", "")
|
||||||
|
if parameter:
|
||||||
|
if parameter not in task["parameters"]:
|
||||||
|
raise HTTPException(422, "参数未在学习任务中声明")
|
||||||
|
value = task["parameters"][parameter]
|
||||||
|
if kind == "read" and not NAME.fullmatch(values.get("output_name") or ""):
|
||||||
|
raise HTTPException(422, "读取步骤需要输出字段名,例如 results")
|
||||||
|
result = await self.call(task_id, "action", {"kind": kind, "selector": values["selector"], "value": value, "output_name": values.get("output_name")})
|
||||||
|
before = self.save_observation(task_id, result["before"], "动作前", "action") if result.get("before") else last
|
||||||
|
after = self.save_observation(task_id, result["after"], "动作后", "action") if result.get("after") else None
|
||||||
|
step = {"kind": kind, "selector": values["selector"], "evidence_id": before["id"], "after_evidence_id": after["id"] if after else None}
|
||||||
|
if kind == "fill":
|
||||||
|
step.update({"parameter": parameter} if parameter else {"value": value})
|
||||||
|
if kind == "read":
|
||||||
|
step["output_name"] = values["output_name"]
|
||||||
|
with self.store.connect(immediate=True) as conn:
|
||||||
|
position = conn.execute("SELECT COALESCE(MAX(position),0)+1 AS position FROM learning_steps WHERE task_id=$1", (task_id,)).fetchone()["position"]
|
||||||
|
step_id, timestamp = identifier("step"), now()
|
||||||
|
conn.execute("INSERT INTO learning_steps(id,task_id,position,data_json,created_at) VALUES($1,$2,$3,$4,$5)", (step_id, task_id, position, encoded(step), timestamp))
|
||||||
|
return {"step": {"id": step_id, "position": position, "created_at": timestamp, **step}, "result": result.get("result"), "before": before, "after": after}
|
||||||
|
|
||||||
|
async def close(self, task_id: str, owner_id: str) -> dict:
|
||||||
|
self.task(task_id, owner_id)
|
||||||
|
result = await self.call(task_id, "close", {})
|
||||||
|
self.sessions.pop(task_id, None)
|
||||||
|
self.pause_active_task(task_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def create_candidate(self, task_id: str, owner_id: str, name: str | None = None) -> dict:
|
||||||
|
task = self.task(task_id, owner_id)
|
||||||
|
steps = [{k: v for k, v in step.items() if k not in ("id", "created_at", "position")} for step in self.steps(task_id)]
|
||||||
|
references = sorted({step[key] for step in steps for key in ("evidence_id", "after_evidence_id") if step.get(key)})
|
||||||
|
bindings = []
|
||||||
|
for reference in references:
|
||||||
|
evidence = self.evidence_snapshot(task_id, reference)
|
||||||
|
bindings.append({"observation_id": reference, "content_digest": digest(evidence) if evidence else None})
|
||||||
|
manifest = {"schema_version": 1, "integrity_version": 1, "entry_url": task["entry_url"], "parameters": deepcopy(task["parameters"]), "steps": steps, "evidence_bindings": bindings, "platform": task["platform"], "identity_requirement": "operator_confirmed_session", "scope": "readonly_learning"}
|
||||||
|
candidate_id = identifier("candidate")
|
||||||
|
with self.store.connect(immediate=True) as conn:
|
||||||
|
version = conn.execute("SELECT COALESCE(MAX(version),0)+1 AS version FROM candidates WHERE task_id=$1", (task_id,)).fetchone()["version"]
|
||||||
|
conn.execute("INSERT INTO candidates(id,task_id,name,version,digest,manifest_json,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)", (candidate_id, task_id, name or task["name"], version, digest(manifest), encoded(manifest), now()))
|
||||||
|
self.set_status(task_id, "candidate")
|
||||||
|
return self.candidate(candidate_id, owner_id)
|
||||||
|
|
||||||
|
def evidence_snapshot(self, task_id: str, observation_id: str) -> dict | None:
|
||||||
|
row = self.store.one("SELECT * FROM observations WHERE id=$1 AND task_id=$2", (observation_id, task_id))
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
data = json.loads(row["data_json"])
|
||||||
|
artifact = self.store.one("SELECT id,sha256,bytes FROM artifacts WHERE id=$1 AND task_id=$2", (data.get("artifact_id", ""), task_id))
|
||||||
|
return {"observation_id": row["id"], "task_id": task_id, "created_at": row["created_at"], "data": data, "artifact": artifact}
|
||||||
|
|
||||||
|
def source_integrity_errors(self, candidate: dict) -> list[str]:
|
||||||
|
manifest, task_id = candidate["manifest"], candidate["task_id"]
|
||||||
|
if manifest.get("integrity_version") != 1:
|
||||||
|
return ["候选尚未冻结动作前后证据,请基于原任务生成新版本"]
|
||||||
|
bindings = manifest.get("evidence_bindings") or []
|
||||||
|
references = {item.get("observation_id") for item in bindings}
|
||||||
|
current = {reference: snapshot for reference in references if (snapshot := self.evidence_snapshot(task_id, reference))}
|
||||||
|
errors = evidence_graph_errors(manifest.get("steps") or [], bindings, current)
|
||||||
|
for snapshot in current.values():
|
||||||
|
artifact = snapshot["artifact"]
|
||||||
|
if not artifact:
|
||||||
|
errors.append("冻结来源证据缺少关联截图")
|
||||||
|
continue
|
||||||
|
stored = self.store.one("SELECT relative_path FROM artifacts WHERE id=$1 AND task_id=$2", (artifact["id"], task_id))
|
||||||
|
target = (self.store.home / stored["relative_path"]).resolve()
|
||||||
|
if not target.is_relative_to(self.store.home) or not target.is_file():
|
||||||
|
errors.append("冻结来源截图不存在或越出证据目录")
|
||||||
|
elif hashlib.sha256(target.read_bytes()).hexdigest() != artifact["sha256"]:
|
||||||
|
errors.append("冻结来源截图摘要不匹配")
|
||||||
|
return list(dict.fromkeys(errors))
|
||||||
|
|
||||||
|
def add_validation(self, candidate: dict, kind: str, errors: list[str], **data) -> dict:
|
||||||
|
record_id, timestamp = identifier("validation"), now()
|
||||||
|
content = {"candidate_digest": candidate["digest"], "errors": errors, "parameters": {}, "expected_outputs": {}, "actual_outputs": {}, "environment": {}, "evidence": [], "sample_name": "", "duration_ms": 0, "model_calls": 0, "model_usage_status": "not_used", **data}
|
||||||
|
status = "failed" if errors else "passed"
|
||||||
|
self.store.execute("INSERT INTO validations(id,candidate_id,task_id,kind,status,data_json,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)", (record_id, candidate["id"], candidate["task_id"], kind, status, encoded(content), timestamp))
|
||||||
|
return {"id": record_id, "candidate_id": candidate["id"], "task_id": candidate["task_id"], "kind": kind, "status": status, "created_at": timestamp, **content}
|
||||||
|
|
||||||
|
def static_errors(self, candidate: dict) -> list[str]:
|
||||||
|
manifest = candidate["manifest"]
|
||||||
|
errors = []
|
||||||
|
if digest(manifest) != candidate["digest"]:
|
||||||
|
errors.append("能力清单校验值不匹配")
|
||||||
|
if manifest.get("schema_version") != 1:
|
||||||
|
errors.append("不支持的能力契约版本")
|
||||||
|
errors.extend(self.source_integrity_errors(candidate))
|
||||||
|
steps = manifest.get("steps") or []
|
||||||
|
if not steps:
|
||||||
|
errors.append("候选没有实际记录的步骤")
|
||||||
|
if len(steps) > 60:
|
||||||
|
errors.append("候选超过步骤预算")
|
||||||
|
output_names = set()
|
||||||
|
for position, step in enumerate(steps, 1):
|
||||||
|
prefix = f"第 {position} 步"
|
||||||
|
if step.get("kind") not in ("fill", "click", "read"):
|
||||||
|
errors.append(prefix + "动作不在首版只读范围内")
|
||||||
|
if not isinstance(step.get("selector"), str) or not step["selector"].strip():
|
||||||
|
errors.append(prefix + "缺少定位规则")
|
||||||
|
observation = self.store.one("SELECT data_json FROM observations WHERE id=$1 AND task_id=$2", (step.get("evidence_id", ""), candidate["task_id"]))
|
||||||
|
if not observation:
|
||||||
|
errors.append(prefix + "没有同任务来源证据")
|
||||||
|
else:
|
||||||
|
observed = json.loads(observation["data_json"])
|
||||||
|
element = next((item for item in observed.get("elements", []) if item.get("selector") == step.get("selector")), None)
|
||||||
|
if not element or element.get("sensitive"):
|
||||||
|
errors.append(prefix + "定位规则缺少非敏感页面依据")
|
||||||
|
elif step.get("kind") != "read" and not element.get("unique"):
|
||||||
|
errors.append(prefix + "操作目标不唯一")
|
||||||
|
if step.get("kind") == "fill" and step.get("parameter") and step["parameter"] not in manifest.get("parameters", {}):
|
||||||
|
errors.append(prefix + "参数未声明")
|
||||||
|
if step.get("kind") == "fill" and not step.get("parameter") and not isinstance(step.get("value"), str):
|
||||||
|
errors.append(prefix + "缺少明确输入值或参数绑定")
|
||||||
|
if step.get("kind") == "read":
|
||||||
|
name = step.get("output_name", "")
|
||||||
|
if not NAME.fullmatch(name) or name in output_names:
|
||||||
|
errors.append(prefix + "输出名缺失、非法或重复")
|
||||||
|
output_names.add(name)
|
||||||
|
if not output_names:
|
||||||
|
errors.append("首版候选至少需要一个可核对的读取输出")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def check(self, candidate_id: str, owner_id: str) -> dict:
|
||||||
|
candidate = self.candidate(candidate_id, owner_id)
|
||||||
|
return self.add_validation(candidate, "static", self.static_errors(candidate), environment={"validator": "learning-v1", "os": os_name(platform.system())})
|
||||||
|
|
||||||
|
async def verify(self, candidate_id: str, owner_id: str, values: dict) -> dict:
|
||||||
|
candidate = self.candidate(candidate_id, owner_id)
|
||||||
|
manifest = candidate["manifest"]
|
||||||
|
static = self.store.one(f"SELECT status FROM validations WHERE candidate_id=$1 AND kind='static' ORDER BY {self.store.order_column()} DESC LIMIT 1", (candidate_id,))
|
||||||
|
if not static or static["status"] != "passed" or self.static_errors(candidate):
|
||||||
|
raise HTTPException(409, "请先通过候选固定校验,再进行浏览器试跑")
|
||||||
|
expected = values.get("expected_outputs") or {}
|
||||||
|
output_names = {step.get("output_name") for step in manifest["steps"] if step["kind"] == "read"}
|
||||||
|
if not expected or not set(expected).issubset(output_names):
|
||||||
|
raise HTTPException(422, "请为候选的读取输出填写至少一项预期文本")
|
||||||
|
for expectation in expected.values():
|
||||||
|
items = [expectation] if isinstance(expectation, str) else expectation
|
||||||
|
if not isinstance(items, list) or not items or any(not isinstance(item, str) or not item.strip() or len(item) > 2000 for item in items):
|
||||||
|
raise HTTPException(422, "预期结果需为非空文本或文本列表")
|
||||||
|
overrides = check_parameters(values.get("parameters") or {})
|
||||||
|
if not set(overrides).issubset(manifest["parameters"]):
|
||||||
|
raise HTTPException(422, "试跑包含候选未声明的参数")
|
||||||
|
parameters = {**manifest["parameters"], **overrides}
|
||||||
|
await self.require_ready(candidate["task_id"], owner_id)
|
||||||
|
started = time.monotonic()
|
||||||
|
environment = {"os": os_name(platform.system()), "python_version": platform.python_version(), "runner": "playwright-learning-v1", "identity_verification": "operator_confirmed"}
|
||||||
|
evidence, errors, outputs = [], [], {}
|
||||||
|
try:
|
||||||
|
result = await self.call(candidate["task_id"], "verify", {"entry_url": manifest["entry_url"], "steps": manifest["steps"], "parameters": parameters, "expected_outputs": expected})
|
||||||
|
outputs = result.get("outputs") or {}
|
||||||
|
errors = [str(error) for error in result.get("errors", [])]
|
||||||
|
environment["browser_version"] = str(result.get("browser_version") or "unknown")
|
||||||
|
if environment["browser_version"] == "unknown":
|
||||||
|
errors.append("执行器没有提供实际浏览器版本")
|
||||||
|
if result.get("os") and os_name(result["os"]) != environment["os"]:
|
||||||
|
errors.append("执行器返回的系统与本机实测系统不一致")
|
||||||
|
for capture in result.get("evidence", []):
|
||||||
|
if isinstance(capture, dict):
|
||||||
|
observation = self.save_observation(candidate["task_id"], capture, values.get("sample_name", "变参试跑"), "verification")
|
||||||
|
evidence.append({"observation_id": observation["id"], "artifact_id": observation.get("artifact_id"), "screenshot_url": observation.get("screenshot_url"), "url": observation.get("url"), "title": observation.get("title")})
|
||||||
|
errors.extend(compare_outputs(outputs, expected))
|
||||||
|
if not result.get("passed") and not errors:
|
||||||
|
errors.append("执行器未确认试跑成功")
|
||||||
|
if not evidence:
|
||||||
|
errors.append("试跑缺少页面证据")
|
||||||
|
except HTTPException as error:
|
||||||
|
detail = error.detail
|
||||||
|
# Missing process-owned session is an operational precondition, not a
|
||||||
|
# failed business assertion. Do not fabricate a new validation result.
|
||||||
|
if isinstance(detail, dict) and detail.get("code", "").lower() in ("session_closed", "session_not_found", "worker_closed", "worker_timeout", "awaiting_user"):
|
||||||
|
raise
|
||||||
|
errors.append(detail.get("message", "试跑未完成") if isinstance(detail, dict) else str(detail))
|
||||||
|
record = self.add_validation(candidate, "browser", list(dict.fromkeys(errors)), parameters=parameters, expected_outputs=expected, actual_outputs=outputs, environment=environment, evidence=evidence, sample_name=values.get("sample_name", "变参试跑"), duration_ms=round((time.monotonic() - started) * 1000), assertion_checks=output_assertion_checks(outputs, expected), verification_basis="synthetic_browser_fixture" if manifest.get("platform") == "demo" else "browser_execution_with_fixed_assertions", independent_readback={"status": "not_configured", "independent_identity": False, "message": "当前保存浏览器执行输出并由后台固定比较;尚未接入独立身份与来源读回,不能等同 A2A 的独立业务核验"})
|
||||||
|
self.set_status(candidate["task_id"], "validated" if record["status"] == "passed" else "validation_failed")
|
||||||
|
return record
|
||||||
|
|
||||||
|
def publish_check(self, candidate_id: str, owner_id: str) -> dict:
|
||||||
|
candidate = self.candidate(candidate_id, owner_id)
|
||||||
|
rows = self.validations(owner_id, candidate_id=candidate_id)
|
||||||
|
static = next((row for row in rows if row["kind"] == "static"), None)
|
||||||
|
passed = [row for row in rows if row["kind"] == "browser" and row["status"] == "passed" and row.get("candidate_digest") == candidate["digest"]]
|
||||||
|
samples = {encoded(row["parameters"]) for row in passed}
|
||||||
|
systems = {row.get("environment", {}).get("os") for row in passed}
|
||||||
|
evidence_ok = bool(passed)
|
||||||
|
for row in passed:
|
||||||
|
if not row.get("evidence"):
|
||||||
|
evidence_ok = False
|
||||||
|
for item in row.get("evidence", []):
|
||||||
|
if not item.get("artifact_id"):
|
||||||
|
evidence_ok = False
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self.artifact(item["artifact_id"], owner_id)
|
||||||
|
except HTTPException:
|
||||||
|
evidence_ok = False
|
||||||
|
checks = [
|
||||||
|
{"name": "候选结构与来源校验", "passed": bool(static and static["status"] == "passed" and not self.static_errors(candidate)), "detail": "引用同版本固定校验与原始页面证据"},
|
||||||
|
{"name": "至少两组不同参数试跑", "passed": len(samples) >= 2, "detail": f"已通过 {len(samples)} 组不同参数"},
|
||||||
|
{"name": "Windows 实际试跑", "passed": "windows" in systems, "detail": "只认可当前执行主机记录的真实系统"},
|
||||||
|
{"name": "macOS 实际试跑", "passed": "macos" in systems, "detail": "未在 macOS 执行不能计为通过"},
|
||||||
|
{"name": "试跑证据完整", "passed": evidence_ok, "detail": "各通过记录需具备关联截图证据"},
|
||||||
|
{"name": "独立来源读回", "passed": False, "detail": "本轮仅完成浏览器执行与固定断言,尚未接入独立身份的来源业务读回"},
|
||||||
|
]
|
||||||
|
return {"ready": all(item["passed"] for item in checks), "checks": checks, "candidate_id": candidate_id, "digest": candidate["digest"], "published": False, "message": "当前阶段提供发布预检,尚未接入商家能力分发"}
|
||||||
|
|
||||||
|
def artifact(self, artifact_id: str, owner_id: str) -> Path:
|
||||||
|
row = self.store.one("SELECT a.* FROM artifacts a JOIN learning_tasks t ON t.id=a.task_id WHERE a.id=$1 AND t.owner_id=$2", (artifact_id, owner_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "证据不存在")
|
||||||
|
target = (self.store.home / row["relative_path"]).resolve()
|
||||||
|
if not target.is_relative_to(self.store.home) or not target.is_file():
|
||||||
|
raise HTTPException(404, "证据文件不存在")
|
||||||
|
if hashlib.sha256(target.read_bytes()).hexdigest() != row["sha256"]:
|
||||||
|
raise HTTPException(409, "证据内容已变化,不能当作原验证依据")
|
||||||
|
return target
|
||||||
|
|
||||||
|
async def demo(self, owner_id: str, base_url: str) -> dict:
|
||||||
|
task = self.create_task(owner_id, {"name": "本机搜索详情评论示例", "platform": "demo", "entry_url": base_url.rstrip("/") + "/api/learning/demo-page", "goal": "记录搜索、打开详情与读取评论,通过两组参数核对当前对象与输出", "account_key": "local-demo", "parameters": {"keyword": "建材"}})
|
||||||
|
task_id = task["id"]
|
||||||
|
try:
|
||||||
|
await self.open(task_id, owner_id)
|
||||||
|
await self.ready(task_id, owner_id, "本机演示页面 无平台账号")
|
||||||
|
await self.capture(task_id, owner_id, "示例初始页面")
|
||||||
|
await self.action(task_id, owner_id, {"kind": "fill", "selector": '[data-testid="search-input"]', "parameter": "keyword"})
|
||||||
|
await self.action(task_id, owner_id, {"kind": "click", "selector": '[data-testid="search-button"]'})
|
||||||
|
await self.action(task_id, owner_id, {"kind": "read", "selector": '[data-testid="result"]', "output_name": "results"})
|
||||||
|
await self.action(task_id, owner_id, {"kind": "click", "selector": '[data-testid="detail-link"]'})
|
||||||
|
await self.action(task_id, owner_id, {"kind": "read", "selector": '[data-testid="comment"]', "output_name": "comments"})
|
||||||
|
candidate = self.create_candidate(task_id, owner_id)
|
||||||
|
self.check(candidate["id"], owner_id)
|
||||||
|
for keyword in ("建材", "装修"):
|
||||||
|
await self.verify(candidate["id"], owner_id, {"parameters": {"keyword": keyword}, "expected_outputs": {"results": keyword, "comments": keyword}, "sample_name": f"关键词 {keyword}"})
|
||||||
|
return {"task_id": task_id, "candidate_id": candidate["id"]}
|
||||||
|
finally:
|
||||||
|
if task_id in self.sessions:
|
||||||
|
try:
|
||||||
|
await self.close(task_id, owner_id)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Pure checks adapted from A2A operation knowledge and test readback boundaries.
|
||||||
|
|
||||||
|
Reference: learning/packages/contracts/python/penguin_learning_v1/operation_chain.py
|
||||||
|
(`_references`, `graph_integrity`, `validate_operation_candidate`) and
|
||||||
|
learning/src/learning_m0/test_readback.py. No source package or database is imported.
|
||||||
|
This reduced contract covers ordered, read-only steps, not the A2A operation DTO.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .db import digest
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_graph_errors(steps: list[dict], bindings: list[dict], current: dict[str, dict]) -> list[str]:
|
||||||
|
"""Check the closed set of immutable before/after evidence references."""
|
||||||
|
errors = []
|
||||||
|
references = [step.get(key) for step in steps for key in ("evidence_id", "after_evidence_id")]
|
||||||
|
expected = {reference for reference in references if isinstance(reference, str) and reference}
|
||||||
|
ids = [item.get("observation_id") for item in bindings]
|
||||||
|
if any(not isinstance(value, str) or not value for value in ids) or len(ids) != len(set(ids)):
|
||||||
|
errors.append("冻结证据标识缺失或重复")
|
||||||
|
if set(ids) != expected:
|
||||||
|
errors.append("候选步骤与冻结证据引用不完整对应")
|
||||||
|
for position, step in enumerate(steps, 1):
|
||||||
|
if not step.get("evidence_id") or not step.get("after_evidence_id"):
|
||||||
|
errors.append(f"第 {position} 步缺少动作前后证据")
|
||||||
|
elif step["evidence_id"] == step["after_evidence_id"]:
|
||||||
|
errors.append(f"第 {position} 步动作前后必须是分别采集的证据")
|
||||||
|
for binding in bindings:
|
||||||
|
reference = binding.get("observation_id")
|
||||||
|
actual = current.get(reference)
|
||||||
|
if actual is None:
|
||||||
|
errors.append("冻结证据缺少同任务的原始记录")
|
||||||
|
elif digest(actual) != binding.get("content_digest"):
|
||||||
|
errors.append("来源证据与候选冻结摘要不一致")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def output_assertion_checks(actual: dict, expected: dict) -> list[dict]:
|
||||||
|
"""Deterministic assertions on saved outputs; this is not an independent read."""
|
||||||
|
checks = []
|
||||||
|
for name, expectation in expected.items():
|
||||||
|
value = actual.get(name)
|
||||||
|
valid = isinstance(value, list) and all(isinstance(item, dict) and isinstance(item.get("text"), str) and set(item) <= {"text", "href"} and ("href" not in item or isinstance(item["href"], str)) for item in value)
|
||||||
|
checks.append({"name": f"output_schema:{name}", "passed": valid, "message": "读取输出结构符合固定契约" if valid else f"输出 {name} 不符合文本读取结构"})
|
||||||
|
# A URL containing the keyword is not evidence that the rendered result
|
||||||
|
# text contains it. Only the explicitly read text participates.
|
||||||
|
text = "\n".join(item["text"] for item in value) if valid else ""
|
||||||
|
checks.append({"name": f"output_present:{name}", "passed": bool(text.strip()), "message": f"输出 {name} 未读到内容" if not text.strip() else "已读到输出"})
|
||||||
|
for item in [expectation] if isinstance(expectation, str) else expectation:
|
||||||
|
checks.append({"name": f"output_contains:{name}", "passed": item in text, "message": "预期文本匹配" if item in text else f"输出 {name} 未包含预期文本:{item[:100]}"})
|
||||||
|
return checks
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import quote, urlsplit
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Request, Response
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .auth import Auth
|
||||||
|
from .db import StoreError, configured_store
|
||||||
|
from .learning import LearningService
|
||||||
|
from .worker import BrowserWorker
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class Input(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class Credentials(Input):
|
||||||
|
username: str = Field(min_length=1, max_length=64)
|
||||||
|
password: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class NewTask(Input):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
platform: Literal["demo", "xiaohongshu", "douyin", "other"]
|
||||||
|
entry_url: str = Field(min_length=1, max_length=2000)
|
||||||
|
goal: str = Field(default="", max_length=4000)
|
||||||
|
account_key: str = Field(min_length=2, max_length=80)
|
||||||
|
parameters: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Ready(Input):
|
||||||
|
identity_label: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class Capture(Input):
|
||||||
|
note: str = Field(default="", max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class Action(Input):
|
||||||
|
kind: Literal["fill", "click", "read"]
|
||||||
|
selector: str = Field(min_length=1, max_length=1000)
|
||||||
|
value: str | None = Field(default=None, max_length=2000)
|
||||||
|
parameter: str | None = Field(default=None, max_length=64)
|
||||||
|
output_name: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class Candidate(Input):
|
||||||
|
name: str | None = Field(default=None, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class Verify(Input):
|
||||||
|
parameters: dict[str, str] = Field(default_factory=dict)
|
||||||
|
expected_outputs: dict[str, str | list[str]] = Field(default_factory=dict)
|
||||||
|
sample_name: str = Field(default="变参试跑", max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(home: Path | None = None, worker=None, *, bootstrap_admin: bool = True,
|
||||||
|
database_url: str | None = None, database_schema: str | None = None) -> FastAPI:
|
||||||
|
home = (home or Path(os.environ.get("ASSISTANT_LEARNING_HOME", ROOT / ".runtime" / "learning"))).resolve()
|
||||||
|
store = configured_store(home, database_url, schema=database_schema)
|
||||||
|
auth = Auth(store)
|
||||||
|
if bootstrap_admin:
|
||||||
|
auth.bootstrap_admin()
|
||||||
|
browser = worker or BrowserWorker(ROOT, home)
|
||||||
|
learning = LearningService(store, browser)
|
||||||
|
instance_id = os.environ.get("ASSISTANT_INSTANCE_ID") or uuid4().hex
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(application):
|
||||||
|
yield
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
app = FastAPI(title="Assistant 学习维护后台", version="0.1.0", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
app.state.store, app.state.auth, app.state.learning, app.state.browser = store, auth, learning, browser
|
||||||
|
extra_origins = {value.strip().rstrip("/") for value in os.environ.get("ASSISTANT_ALLOWED_ORIGINS", "").split(",") if value.strip()}
|
||||||
|
extra_origins.update({"http://127.0.0.1:5179", "http://localhost:5179"})
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def local_boundary(request: Request, call_next):
|
||||||
|
try:
|
||||||
|
host = urlsplit("http://" + request.headers.get("host", "")).hostname
|
||||||
|
except ValueError:
|
||||||
|
host = None
|
||||||
|
if host not in {"127.0.0.1", "localhost", "::1"}:
|
||||||
|
return JSONResponse({"detail": "学习维护后台仅接受本机访问"}, status_code=400)
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
own_origin = str(request.base_url).rstrip("/")
|
||||||
|
if origin and origin.rstrip("/") not in extra_origins | {own_origin}:
|
||||||
|
return JSONResponse({"detail": "请求来源不在维护后台允许范围内"}, status_code=403)
|
||||||
|
response = await call_next(request)
|
||||||
|
if request.url.path.startswith("/api/"):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "same-origin"
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_error(request, error):
|
||||||
|
fields = ", ".join(".".join(str(part) for part in item["loc"] if part != "body") for item in error.errors())
|
||||||
|
return JSONResponse({"detail": "输入格式不正确,请检查:" + fields}, status_code=422)
|
||||||
|
|
||||||
|
@app.exception_handler(StoreError)
|
||||||
|
async def database_error(request, error: StoreError):
|
||||||
|
return JSONResponse({"detail": {"code": error.code, "message": error.message}}, status_code=error.status)
|
||||||
|
|
||||||
|
def operator(request: Request) -> str:
|
||||||
|
return auth.operator(request, mutate=request.method not in {"GET", "HEAD", "OPTIONS"})
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "service": "assistant-learning", "version": "0.1.0", "mode": "local_maintainer", "model_connected": False, "instance_id": instance_id, "database_backend": store.backend}
|
||||||
|
|
||||||
|
@app.get("/api/auth/status")
|
||||||
|
def auth_status(request: Request, response: Response):
|
||||||
|
return auth.status(request, response)
|
||||||
|
|
||||||
|
@app.post("/api/auth/setup")
|
||||||
|
def setup(body: Credentials, request: Request, response: Response):
|
||||||
|
return auth.setup(request, response, body.username, body.password)
|
||||||
|
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
def login(body: Credentials, request: Request, response: Response):
|
||||||
|
return auth.login(request, response, body.username, body.password)
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout")
|
||||||
|
def logout(request: Request, response: Response):
|
||||||
|
return auth.logout(request, response)
|
||||||
|
|
||||||
|
@app.get("/api/learning/overview")
|
||||||
|
def overview(owner: str = Depends(operator)):
|
||||||
|
return learning.overview(owner)
|
||||||
|
|
||||||
|
@app.get("/api/learning/tasks")
|
||||||
|
async def tasks(owner: str = Depends(operator)):
|
||||||
|
for task_id in list(learning.sessions):
|
||||||
|
if store.one("SELECT id FROM learning_tasks WHERE id=$1 AND owner_id=$2", (task_id, owner)):
|
||||||
|
await learning.refresh_browser(task_id, owner)
|
||||||
|
items = learning.tasks(owner)
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
@app.post("/api/learning/tasks", status_code=201)
|
||||||
|
def new_task(body: NewTask, owner: str = Depends(operator)):
|
||||||
|
return learning.create_task(owner, body.model_dump())
|
||||||
|
|
||||||
|
@app.get("/api/learning/tasks/{task_id}")
|
||||||
|
async def task_detail(task_id: str, owner: str = Depends(operator)):
|
||||||
|
await learning.refresh_browser(task_id, owner)
|
||||||
|
return learning.detail(task_id, owner)
|
||||||
|
|
||||||
|
@app.post("/api/learning/tasks/{task_id}/browser/open")
|
||||||
|
async def open_browser(task_id: str, owner: str = Depends(operator)):
|
||||||
|
return await learning.open(task_id, owner)
|
||||||
|
|
||||||
|
@app.post("/api/learning/tasks/{task_id}/browser/ready")
|
||||||
|
async def ready_browser(task_id: str, body: Ready, owner: str = Depends(operator)):
|
||||||
|
return await learning.ready(task_id, owner, body.identity_label)
|
||||||
|
|
||||||
|
@app.post("/api/learning/tasks/{task_id}/browser/capture")
|
||||||
|
async def capture_browser(task_id: str, body: Capture | None = None, owner: str = Depends(operator)):
|
||||||
|
return await learning.capture(task_id, owner, body.note if body else "")
|
||||||
|
|
||||||
|
@app.post("/api/learning/tasks/{task_id}/browser/action")
|
||||||
|
async def action_browser(task_id: str, body: Action, owner: str = Depends(operator)):
|
||||||
|
return await learning.action(task_id, owner, body.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
@app.post("/api/learning/tasks/{task_id}/browser/close")
|
||||||
|
async def close_browser(task_id: str, owner: str = Depends(operator)):
|
||||||
|
return await learning.close(task_id, owner)
|
||||||
|
|
||||||
|
@app.post("/api/learning/tasks/{task_id}/candidates", status_code=201)
|
||||||
|
def new_candidate(task_id: str, body: Candidate | None = None, owner: str = Depends(operator)):
|
||||||
|
return learning.create_candidate(task_id, owner, body.name if body else None)
|
||||||
|
|
||||||
|
@app.get("/api/learning/candidates")
|
||||||
|
def candidates(owner: str = Depends(operator)):
|
||||||
|
items = learning.candidates(owner)
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
@app.get("/api/learning/candidates/{candidate_id}")
|
||||||
|
def candidate_detail(candidate_id: str, owner: str = Depends(operator)):
|
||||||
|
return {**learning.candidate(candidate_id, owner), "validations": learning.validations(owner, candidate_id=candidate_id)}
|
||||||
|
|
||||||
|
@app.post("/api/learning/candidates/{candidate_id}/check")
|
||||||
|
def check_candidate(candidate_id: str, owner: str = Depends(operator)):
|
||||||
|
return learning.check(candidate_id, owner)
|
||||||
|
|
||||||
|
@app.post("/api/learning/candidates/{candidate_id}/verify")
|
||||||
|
async def verify_candidate(candidate_id: str, body: Verify, owner: str = Depends(operator)):
|
||||||
|
return await learning.verify(candidate_id, owner, body.model_dump())
|
||||||
|
|
||||||
|
@app.post("/api/learning/candidates/{candidate_id}/publish-check")
|
||||||
|
def publish_check(candidate_id: str, owner: str = Depends(operator)):
|
||||||
|
return learning.publish_check(candidate_id, owner)
|
||||||
|
|
||||||
|
@app.get("/api/learning/validations")
|
||||||
|
def validations(owner: str = Depends(operator)):
|
||||||
|
items = learning.validations(owner)
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
@app.get("/api/learning/artifacts/{artifact_id}")
|
||||||
|
def artifact(artifact_id: str, owner: str = Depends(operator)):
|
||||||
|
return FileResponse(learning.artifact(artifact_id, owner), media_type="image/png")
|
||||||
|
|
||||||
|
# Fixture pages intentionally contain synthetic public content, no operator or platform data.
|
||||||
|
@app.get("/api/learning/demo-page", response_class=HTMLResponse)
|
||||||
|
def demo_page(q: str = ""):
|
||||||
|
term = q.strip()[:120] or "建材"
|
||||||
|
safe = escape(term, quote=True)
|
||||||
|
cards = ""
|
||||||
|
for i in range(1, 4):
|
||||||
|
detail_attr = 'data-testid="detail-link"' if i == 1 else ""
|
||||||
|
cards += f'<article data-testid="result"><h2>{safe}采购需求 {i}</h2><p>本机演示内容,用于验证关键词与读取对象是否对应。</p><a {detail_attr} href="/api/learning/demo-detail/{i}?q={quote(term)}">查看详情 {i}</a></article>'
|
||||||
|
return HTMLResponse(f'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>本机链路验证页面</title><style>body{{font:16px/1.6 system-ui,sans-serif;max-width:860px;margin:50px auto;padding:0 24px;color:#202b35;background:#f6f7f8}}h1{{font-size:25px}}form{{display:flex;gap:10px;margin:24px 0}}input,button{{font:inherit;padding:9px 12px}}article{{padding:18px;background:white;border:1px solid #dce2e6;margin:14px 0;border-radius:8px}}h2{{font-size:18px;margin:0}}a{{color:#14695e}}</style><h1>本机搜索链路</h1><p>这是本项目的演示页面,不连接小红书或抖音,也不产生真实客户线索。</p><form method="get"><input data-testid="search-input" name="q" aria-label="搜索关键词" value="{escape(q,quote=True)}"><button data-testid="search-button" type="submit">搜索</button></form><main>{cards}</main></html>')
|
||||||
|
|
||||||
|
@app.get("/api/learning/demo-detail/{item}", response_class=HTMLResponse)
|
||||||
|
def demo_detail(item: int, q: str = "建材"):
|
||||||
|
safe = escape(q[:120], quote=True)
|
||||||
|
return HTMLResponse(f'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>演示详情与评论</title><style>body{{font:16px/1.7 system-ui;max-width:850px;margin:50px auto;padding:0 24px}}article{{padding:16px;border-bottom:1px solid #ddd}}</style><h1>{safe}采购需求 {item}</h1><p>本机演示作者,不是真实平台账号。</p><section><h2>评论</h2><article data-testid="comment">演示评论甲:正在了解{safe},需要比较规格。</article><article data-testid="comment">演示评论乙:{safe}适用场景有哪些?</article></section><a href="/api/learning/demo-page?q={quote(q[:120])}">返回搜索</a></html>')
|
||||||
|
|
||||||
|
@app.post("/api/learning/demo")
|
||||||
|
async def run_demo(request: Request, owner: str = Depends(operator)):
|
||||||
|
return await learning.demo(owner, str(request.base_url))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_versions(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS operators(
|
||||||
|
id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_sessions(
|
||||||
|
token_hash TEXT PRIMARY KEY, operator_id TEXT REFERENCES operators(id), csrf TEXT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_attempts(key TEXT PRIMARY KEY, failures INTEGER NOT NULL, window_start BIGINT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_tasks(
|
||||||
|
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES operators(id), name TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL, entry_url TEXT NOT NULL, goal TEXT NOT NULL, account_key TEXT NOT NULL,
|
||||||
|
parameters_json TEXT NOT NULL, status TEXT NOT NULL, identity_label TEXT,
|
||||||
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
|
ordinal BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS observations(
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES learning_tasks(id), data_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL, ordinal BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS observations_task ON observations(task_id,ordinal);
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_steps(
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES learning_tasks(id), position INTEGER NOT NULL,
|
||||||
|
data_json TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(task_id,position)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS candidates(
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES learning_tasks(id), name TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL, digest TEXT NOT NULL, manifest_json TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||||
|
ordinal BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE, UNIQUE(task_id,version)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS validations(
|
||||||
|
id TEXT PRIMARY KEY, candidate_id TEXT NOT NULL REFERENCES candidates(id), task_id TEXT NOT NULL REFERENCES learning_tasks(id),
|
||||||
|
kind TEXT NOT NULL, status TEXT NOT NULL, data_json TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||||
|
ordinal BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS validations_candidate ON validations(candidate_id,ordinal);
|
||||||
|
CREATE TABLE IF NOT EXISTS artifacts(
|
||||||
|
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES learning_tasks(id), relative_path TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL, bytes BIGINT NOT NULL, created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE OR REPLACE FUNCTION reject_learning_fact_mutation() RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql AS $guard$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'learning facts are immutable and append only' USING ERRCODE='23514';
|
||||||
|
END;
|
||||||
|
$guard$;
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
fastapi==0.159.1
|
||||||
|
uvicorn==0.54.0
|
||||||
|
pydantic==2.14.1
|
||||||
|
httpx==0.28.1
|
||||||
|
pytest==9.1.1
|
||||||
|
psycopg[binary]==3.2.10
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Isolate import-time ASGI construction before pytest collects test modules."""
|
||||||
|
import os
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
|
||||||
|
_import_home = TemporaryDirectory(prefix="assistant-server-test-import-")
|
||||||
|
_previous = {key: os.environ.get(key) for key in ("ASSISTANT_LEARNING_HOME", "ASSISTANT_DATABASE_URL")}
|
||||||
|
os.environ["ASSISTANT_LEARNING_HOME"] = _import_home.name
|
||||||
|
os.environ["ASSISTANT_DATABASE_URL"] = ""
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_sessionfinish(session, exitstatus):
|
||||||
|
for key, value in _previous.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
else:
|
||||||
|
os.environ[key] = value
|
||||||
|
_import_home.cleanup()
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from apps.server.auth import Auth, password_hash, password_matches
|
||||||
|
from apps.server.db import Store, identifier, now
|
||||||
|
from apps.server.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class UnusedWorker:
|
||||||
|
async def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_admin_is_hashed_and_can_log_in(tmp_path):
|
||||||
|
app = create_app(tmp_path, UnusedWorker(), database_url="")
|
||||||
|
with TestClient(app, base_url="http://127.0.0.1") as client:
|
||||||
|
status = client.get("/api/auth/status").json()
|
||||||
|
assert status["initialized"] and not status["authenticated"]
|
||||||
|
client.headers["X-CSRF-Token"] = status["csrf_token"]
|
||||||
|
login = client.post("/api/auth/login", json={"username": "admin", "password": "123456"})
|
||||||
|
assert login.status_code == 200
|
||||||
|
assert login.json()["user"]["username"] == "admin"
|
||||||
|
stored = app.state.store.one("SELECT password_hash FROM operators WHERE username='admin'")["password_hash"]
|
||||||
|
assert stored.startswith("scrypt$") and password_matches("123456", stored)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("username", ["admin", "existing-maintainer"])
|
||||||
|
def test_restart_preserves_existing_account_and_password(tmp_path, username):
|
||||||
|
store = Store(tmp_path)
|
||||||
|
stored = password_hash("Existing-password-4831")
|
||||||
|
account_id, created_at = identifier("operator"), now()
|
||||||
|
store.execute("INSERT INTO operators VALUES(?,?,?,?)", (account_id, username, stored, created_at))
|
||||||
|
before = store.rows("SELECT * FROM operators")
|
||||||
|
# Repeated normal starts cannot replace a changed admin hash or add an admin
|
||||||
|
# alongside a different already initialized maintainer.
|
||||||
|
first = create_app(tmp_path, UnusedWorker(), database_url="")
|
||||||
|
second = create_app(tmp_path, UnusedWorker(), database_url="")
|
||||||
|
assert first.state.store.rows("SELECT * FROM operators") == before
|
||||||
|
assert second.state.store.rows("SELECT * FROM operators") == before
|
||||||
|
assert not password_matches("123456", stored)
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_bootstrap_creates_exactly_one_admin(tmp_path):
|
||||||
|
store = Store(tmp_path)
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
created = list(pool.map(lambda _: Auth(store).bootstrap_admin(), range(4)))
|
||||||
|
assert created.count(True) == 1 and created.count(False) == 3
|
||||||
|
rows = store.rows("SELECT * FROM operators")
|
||||||
|
assert len(rows) == 1 and rows[0]["username"] == "admin"
|
||||||
|
assert password_matches("123456", rows[0]["password_hash"])
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import platform
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from apps.server.main import create_app
|
||||||
|
from apps.server.worker import WorkerError
|
||||||
|
|
||||||
|
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=")
|
||||||
|
PASSWORD = "Test-only-maintainer-4829"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWorker:
|
||||||
|
"""Contract substitute only; actual browser proof is a separate E2E check."""
|
||||||
|
def __init__(self, home: Path):
|
||||||
|
self.home, self.sessions, self.counter, self.calls = home, {}, 0, []
|
||||||
|
|
||||||
|
def capture(self, task_id):
|
||||||
|
self.counter += 1
|
||||||
|
path = self.home / "evidence" / f"test-{self.counter}.png"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(PNG)
|
||||||
|
return {"url": "http://127.0.0.1/demo", "title": "本机测试", "text": "测试页面", "elements": [{"selector": selector, "tag": tag, "label": label, "kind": kind, "unique": unique, "sensitive": False} for selector, tag, label, kind, unique in [('[data-testid="search-input"]', "input", "搜索", "fill", True), ('[data-testid="search-button"]', "button", "搜索", "click", True), ('[data-testid="result"]', "article", "结果", "read", False), ('[data-testid="detail-link"]', "a", "查看详情", "click", True), ('[data-testid="comment"]', "article", "评论", "read", False)]], "screenshot_path": str(path)}
|
||||||
|
|
||||||
|
async def call(self, method, params):
|
||||||
|
self.calls.append((method, copy.deepcopy(params)))
|
||||||
|
task_id = params["task_id"]
|
||||||
|
if method == "open":
|
||||||
|
self.sessions[task_id] = {"ready": False, "keyword": "建材"}
|
||||||
|
return {"session_id": task_id, "status": "awaiting_user", "url": params["entry_url"]}
|
||||||
|
session = self.sessions.get(task_id)
|
||||||
|
if method == "status":
|
||||||
|
return {"status": "observing" if session and session["ready"] else "awaiting_user" if session else "closed", "manual_confirmation": bool(session and session["ready"])}
|
||||||
|
if not session:
|
||||||
|
raise WorkerError("请先打开会话", "session_not_found")
|
||||||
|
if method == "ready":
|
||||||
|
session["ready"] = True
|
||||||
|
return {"manual_confirmation": True}
|
||||||
|
if method == "close":
|
||||||
|
self.sessions.pop(task_id)
|
||||||
|
return {"closed": True}
|
||||||
|
if not session["ready"]:
|
||||||
|
raise WorkerError("等待人工登录确认", "not_ready")
|
||||||
|
if method == "capture":
|
||||||
|
return self.capture(task_id)
|
||||||
|
if method == "action":
|
||||||
|
if params["kind"] == "fill":
|
||||||
|
session["keyword"] = params["value"]
|
||||||
|
return {"result": [{"text": f"{session['keyword']}采购需求"}] if params["kind"] == "read" else {"completed": True}, "before": self.capture(task_id), "after": self.capture(task_id)}
|
||||||
|
if method == "verify":
|
||||||
|
value = params["parameters"]["keyword"]
|
||||||
|
return {"passed": True, "outputs": {"results": [{"text": value + "采购需求"}], "comments": [{"text": value + "评论"}]}, "errors": [], "evidence": [self.capture(task_id)], "os": platform.system(), "browser_version": "test-browser"}
|
||||||
|
raise AssertionError(method)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.sessions.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
worker = FakeWorker(tmp_path)
|
||||||
|
app = create_app(tmp_path, worker, bootstrap_admin=False, database_url="")
|
||||||
|
with TestClient(app, base_url="http://127.0.0.1:8810") as test_client:
|
||||||
|
test_client.app_reference = app
|
||||||
|
test_client.worker_reference = worker
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def initialize(client):
|
||||||
|
status = client.get("/api/auth/status").json()
|
||||||
|
client.headers["X-CSRF-Token"] = status["csrf_token"]
|
||||||
|
response = client.post("/api/auth/setup", json={"username": "maintainer", "password": PASSWORD})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def task(client):
|
||||||
|
response = client.post("/api/learning/tasks", json={"name": "只读搜索", "platform": "demo", "entry_url": "http://127.0.0.1:8810/api/learning/demo-page", "goal": "搜索和读取", "account_key": "test-account", "parameters": {"keyword": "建材"}})
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def working_candidate(client):
|
||||||
|
created = task(client)
|
||||||
|
prefix = "/api/learning/tasks/" + created["id"]
|
||||||
|
assert client.post(prefix + "/browser/open").status_code == 200
|
||||||
|
assert client.post(prefix + "/browser/ready", json={"identity_label": "测试账号"}).status_code == 200
|
||||||
|
assert client.post(prefix + "/browser/capture", json={}).status_code == 200
|
||||||
|
for action in [{"kind": "fill", "selector": '[data-testid="search-input"]', "parameter": "keyword"}, {"kind": "click", "selector": '[data-testid="search-button"]'}, {"kind": "read", "selector": '[data-testid="result"]', "output_name": "results"}]:
|
||||||
|
response = client.post(prefix + "/browser/action", json=action)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
response = client.post(prefix + "/candidates", json={})
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return created, response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_setup_rotates_session_and_requires_csrf(client):
|
||||||
|
assert client.get("/api/learning/tasks").status_code == 401
|
||||||
|
status = client.get("/api/auth/status").json()
|
||||||
|
assert not status["initialized"] and not status["authenticated"]
|
||||||
|
assert client.post("/api/auth/setup", json={"username": "maintainer", "password": PASSWORD}).status_code == 403
|
||||||
|
old_cookie = client.cookies.get("assistant_maintainer")
|
||||||
|
client.headers["X-CSRF-Token"] = status["csrf_token"]
|
||||||
|
result = client.post("/api/auth/setup", json={"username": "maintainer", "password": PASSWORD})
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert result.json()["authenticated"]
|
||||||
|
assert client.cookies.get("assistant_maintainer") != old_cookie
|
||||||
|
assert result.json()["csrf_token"] != status["csrf_token"]
|
||||||
|
assert "httponly" in result.headers["set-cookie"].lower()
|
||||||
|
assert "samesite=strict" in result.headers["set-cookie"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_is_one_time_and_password_not_returned(client):
|
||||||
|
result = initialize(client)
|
||||||
|
assert "password" not in str(result)
|
||||||
|
assert client.post("/api/auth/setup", json={"username": "second", "password": PASSWORD}).status_code == 409
|
||||||
|
row = client.app_reference.state.store.one("SELECT password_hash FROM operators")
|
||||||
|
assert row["password_hash"].startswith("scrypt$") and PASSWORD not in row["password_hash"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_logout_login_and_bad_origin(client):
|
||||||
|
initialize(client)
|
||||||
|
assert client.get("/api/auth/status", headers={"Origin": "https://unrelated.example"}).status_code == 403
|
||||||
|
assert client.get("/api/health", headers={"Host": "unrelated.example"}).status_code == 400
|
||||||
|
assert client.post("/api/learning/tasks", json={}, headers={"X-CSRF-Token": "invalid"}).status_code == 403
|
||||||
|
response = client.post("/api/auth/logout")
|
||||||
|
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
|
||||||
|
assert client.get("/api/learning/tasks").status_code == 401
|
||||||
|
assert client.post("/api/auth/login", json={"username": "maintainer", "password": "wrong"}).status_code == 401
|
||||||
|
response = client.post("/api/auth/login", json={"username": "maintainer", "password": PASSWORD})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_is_authenticated_and_scoped(client):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
detail = client.get("/api/learning/tasks/" + created["id"]).json()
|
||||||
|
image_url = detail["observations"][0]["screenshot_url"]
|
||||||
|
assert client.get(image_url).content.startswith(b"\x89PNG")
|
||||||
|
assert "screenshot_path" not in str(detail)
|
||||||
|
response = client.post("/api/auth/logout")
|
||||||
|
assert client.get(image_url).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_is_immutable_and_check_records_append(client):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
url = "/api/learning/candidates/" + candidate["id"]
|
||||||
|
first = client.post(url + "/check").json()
|
||||||
|
second = client.post(url + "/check").json()
|
||||||
|
assert first["status"] == second["status"] == "passed"
|
||||||
|
assert first["id"] != second["id"]
|
||||||
|
assert len(client.get(url).json()["validations"]) == 2
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
client.app_reference.state.store.execute("UPDATE candidates SET manifest_json='{}' WHERE id=?", (candidate["id"],))
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
client.app_reference.state.store.execute("UPDATE validations SET status='failed' WHERE id=?", (first["id"],))
|
||||||
|
second_candidate = client.post("/api/learning/tasks/" + created["id"] + "/candidates", json={}).json()
|
||||||
|
assert second_candidate["version"] == candidate["version"] + 1
|
||||||
|
assert second_candidate["digest"] == candidate["digest"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_validation_rejects_empty_candidate(client):
|
||||||
|
initialize(client)
|
||||||
|
created = task(client)
|
||||||
|
candidate = client.post("/api/learning/tasks/" + created["id"] + "/candidates", json={}).json()
|
||||||
|
check = client.post("/api/learning/candidates/" + candidate["id"] + "/check").json()
|
||||||
|
assert check["status"] == "failed" and check["errors"]
|
||||||
|
response = client.post("/api/learning/candidates/" + candidate["id"] + "/verify", json={"expected_outputs": {"results": "建材"}})
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_outputs_are_compared_independently_and_gates_keep_other_os_missing(client):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
prefix = "/api/learning/candidates/" + candidate["id"]
|
||||||
|
assert client.post(prefix + "/check").json()["status"] == "passed"
|
||||||
|
mismatch = client.post(prefix + "/verify", json={"parameters": {"keyword": "建材"}, "expected_outputs": {"results": "绝不匹配的文本"}}).json()
|
||||||
|
assert mismatch["status"] == "failed"
|
||||||
|
assert "未包含" in " ".join(mismatch["errors"])
|
||||||
|
for keyword in ("建材", "装修"):
|
||||||
|
response = client.post(prefix + "/verify", json={"parameters": {"keyword": keyword}, "expected_outputs": {"results": keyword}})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.json()["status"] == "passed"
|
||||||
|
report = client.post(prefix + "/publish-check").json()
|
||||||
|
assert not report["ready"] and not report["published"]
|
||||||
|
assert next(item for item in report["checks"] if "不同参数" in item["name"])["passed"]
|
||||||
|
expected_os = "macOS" if platform.system() != "Darwin" else "Windows"
|
||||||
|
assert not next(item for item in report["checks"] if expected_os in item["name"])["passed"]
|
||||||
|
forged = client.post(prefix + "/verify", json={"parameters": {"keyword": "建材"}, "expected_outputs": {"results": "建材"}, "target_os": "macos"})
|
||||||
|
assert forged.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_preconditions_and_bad_parameters_are_not_silently_accepted(client):
|
||||||
|
initialize(client)
|
||||||
|
created = task(client)
|
||||||
|
prefix = "/api/learning/tasks/" + created["id"]
|
||||||
|
assert client.post(prefix + "/browser/capture", json={}).status_code == 409
|
||||||
|
client.post(prefix + "/browser/open")
|
||||||
|
assert client.post(prefix + "/browser/capture", json={}).status_code == 409
|
||||||
|
client.post(prefix + "/browser/ready", json={"identity_label": "测试账号"})
|
||||||
|
client.post(prefix + "/browser/capture", json={})
|
||||||
|
assert client.post(prefix + "/browser/action", json={"kind": "fill", "selector": "#not-observed", "value": "x"}).status_code == 422
|
||||||
|
assert client.post(prefix + "/browser/action", json={"kind": "fill", "selector": '[data-testid="search-input"]', "parameter": "missing"}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_uses_worker_and_closes_account_even_after_success(client):
|
||||||
|
initialize(client)
|
||||||
|
response = client.post("/api/learning/demo")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
detail = client.get("/api/learning/tasks/" + response.json()["task_id"]).json()
|
||||||
|
assert len(detail["steps"]) == 5
|
||||||
|
assert len(detail["validations"]) == 3
|
||||||
|
assert detail["browser_status"] == "closed"
|
||||||
|
assert not client.worker_reference.sessions
|
||||||
|
assert len([call for call in client.worker_reference.calls if call[0] == "verify"]) == 2
|
||||||
|
assert client.post("/api/learning/demo").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_history_survives_store_reopen(client, tmp_path):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
client.post("/api/learning/candidates/" + candidate["id"] + "/check")
|
||||||
|
from apps.server.db import Store
|
||||||
|
reopened = Store(tmp_path)
|
||||||
|
assert reopened.one("SELECT count(*) AS n FROM validations")["n"] == 1
|
||||||
|
assert reopened.one("SELECT count(*) AS n FROM learning_tasks")["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_is_public_but_application_data_is_private(client):
|
||||||
|
fixture = client.get("/api/learning/demo-page?q=装修")
|
||||||
|
assert fixture.status_code == 200
|
||||||
|
assert fixture.text.count('data-testid="detail-link"') == 1
|
||||||
|
assert "装修采购需求" in fixture.text
|
||||||
|
assert client.get("/api/learning/overview").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_artifact_mutation_invalidates_the_evidence(client):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
url = client.get("/api/learning/tasks/" + created["id"]).json()["observations"][0]["screenshot_url"]
|
||||||
|
artifact_id = url.rsplit("/", 1)[1]
|
||||||
|
row = client.app_reference.state.store.one("SELECT relative_path FROM artifacts WHERE id=?", (artifact_id,))
|
||||||
|
(client.app_reference.state.store.home / row["relative_path"]).write_bytes(PNG + b"changed")
|
||||||
|
assert client.get(url).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_binds_before_after_evidence_and_refuses_tampered_source(client):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
manifest = candidate["manifest"]
|
||||||
|
assert manifest["integrity_version"] == 1
|
||||||
|
assert len(manifest["evidence_bindings"]) == 6
|
||||||
|
assert all(item["content_digest"] for item in manifest["evidence_bindings"])
|
||||||
|
from apps.server.learning_checks import evidence_graph_errors
|
||||||
|
assert "动作前后" in " ".join(evidence_graph_errors([{"evidence_id": "a"}], [], {}))
|
||||||
|
assert "重复" in " ".join(evidence_graph_errors([], [{"observation_id": "a"}, {"observation_id": "a"}], {}))
|
||||||
|
service = client.app_reference.state.learning
|
||||||
|
changed = copy.deepcopy(candidate)
|
||||||
|
changed["manifest"]["evidence_bindings"][0]["content_digest"] = "0" * 64
|
||||||
|
assert any("冻结摘要" in item for item in service.source_integrity_errors(changed))
|
||||||
|
first = service.observations(created["id"])[1]
|
||||||
|
artifact = service.store.one("SELECT relative_path FROM artifacts WHERE id=?", (first["artifact_id"],))
|
||||||
|
(service.store.home / artifact["relative_path"]).write_bytes(PNG + b"tamper")
|
||||||
|
check = client.post("/api/learning/candidates/" + candidate["id"] + "/check").json()
|
||||||
|
assert check["status"] == "failed" and any("截图摘要" in error for error in check["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_steps_candidates_and_validations_reject_deletion(client):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
client.post("/api/learning/candidates/" + candidate["id"] + "/check")
|
||||||
|
store = client.app_reference.state.store
|
||||||
|
for table in ("observations", "learning_steps", "artifacts", "validations", "candidates"):
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="immutable|append only"):
|
||||||
|
store.execute(f"DELETE FROM {table}")
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
store.execute("UPDATE observations SET data_json='{}' WHERE task_id=?", (created["id"],))
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_invalidates_process_session_without_erasing_history(client, tmp_path):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
prefix = "/api/learning/candidates/" + candidate["id"]
|
||||||
|
client.post(prefix + "/check")
|
||||||
|
client.post("/api/learning/tasks/" + created["id"] + "/browser/ready", json={"identity_label": "测试账号"})
|
||||||
|
restarted = create_app(tmp_path, FakeWorker(tmp_path), bootstrap_admin=False, database_url="")
|
||||||
|
with TestClient(restarted, base_url="http://127.0.0.1:8810") as second:
|
||||||
|
second.cookies.update(client.cookies)
|
||||||
|
second.headers["X-CSRF-Token"] = second.get("/api/auth/status").json()["csrf_token"]
|
||||||
|
detail = second.get("/api/learning/tasks/" + created["id"]).json()
|
||||||
|
assert detail["status"] == "paused"
|
||||||
|
assert not detail["browser_readiness"]["ready"]
|
||||||
|
assert detail["browser_readiness"]["next_action"] == "open_browser"
|
||||||
|
assert len(detail["validations"]) == 1 and len(detail["steps"]) == 3
|
||||||
|
result = second.post(prefix + "/verify", json={"expected_outputs": {"results": "建材"}})
|
||||||
|
assert result.status_code == 409 and result.json()["detail"]["code"] == "browser_session_required"
|
||||||
|
assert len(second.get(prefix).json()["validations"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_browser_close_and_unconfirmed_session_are_preconditions(client):
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
task_prefix = "/api/learning/tasks/" + created["id"]
|
||||||
|
candidate_prefix = "/api/learning/candidates/" + candidate["id"]
|
||||||
|
client.post(candidate_prefix + "/check")
|
||||||
|
client.worker_reference.sessions.clear()
|
||||||
|
detail = client.get(task_prefix).json()
|
||||||
|
assert detail["browser_status"] == "closed" and not detail["browser_readiness"]["ready"]
|
||||||
|
client.post(task_prefix + "/browser/open")
|
||||||
|
result = client.post(candidate_prefix + "/verify", json={"expected_outputs": {"results": "建材"}})
|
||||||
|
assert result.status_code == 409 and result.json()["detail"]["code"] == "browser_confirmation_required"
|
||||||
|
assert len(client.get(candidate_prefix).json()["validations"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixed_readback_assertions_do_not_match_keywords_only_in_links():
|
||||||
|
from apps.server.learning_checks import output_assertion_checks
|
||||||
|
checks = output_assertion_checks({"comments": [{"text": "不相关评论", "href": "https://example.test/?q=建材"}]}, {"comments": "建材"})
|
||||||
|
assert not all(item["passed"] for item in checks)
|
||||||
|
checks = output_assertion_checks({"comments": [{"text": True}]}, {"comments": "True"})
|
||||||
|
assert not next(item for item in checks if item["name"] == "output_schema:comments")["passed"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_identifies_the_exact_started_instance(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("ASSISTANT_INSTANCE_ID", "isolated-test-instance")
|
||||||
|
app = create_app(tmp_path / "explicit", FakeWorker(tmp_path / "explicit"), database_url="")
|
||||||
|
with TestClient(app, base_url="http://127.0.0.1") as test_client:
|
||||||
|
assert test_client.get("/api/health").json()["instance_id"] == "isolated-test-instance"
|
||||||
|
monkeypatch.delenv("ASSISTANT_INSTANCE_ID")
|
||||||
|
random_app = create_app(tmp_path / "random", FakeWorker(tmp_path / "random"), database_url="")
|
||||||
|
with TestClient(random_app, base_url="http://127.0.0.1") as test_client:
|
||||||
|
first = test_client.get("/api/health").json()["instance_id"]
|
||||||
|
assert len(first) == 32 and first != "isolated-test-instance"
|
||||||
|
assert test_client.get("/api/health").json()["instance_id"] == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_shutdown_sends_eof_without_waiting_for_active_rpc_lock(tmp_path):
|
||||||
|
from apps.server.worker import BrowserWorker
|
||||||
|
|
||||||
|
class InputPipe:
|
||||||
|
closed = False
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
class OwnedProcess:
|
||||||
|
stdin = InputPipe()
|
||||||
|
async def wait(self):
|
||||||
|
assert self.stdin.closed
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def check():
|
||||||
|
worker = BrowserWorker(tmp_path, tmp_path)
|
||||||
|
process = OwnedProcess()
|
||||||
|
worker.process = process
|
||||||
|
async with worker.lock:
|
||||||
|
await asyncio.wait_for(asyncio.gather(worker.close(), worker.close()), timeout=0.5)
|
||||||
|
assert process.stdin.closed and worker.process is None
|
||||||
|
with pytest.raises(WorkerError, match="正常退出"):
|
||||||
|
await worker.call("status", {"task_id": "test"})
|
||||||
|
asyncio.run(check())
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""Opt-in real PostgreSQL contract tests, each confined to a new random schema.
|
||||||
|
|
||||||
|
Run with ASSISTANT_TEST_DATABASE_URL pointing at the dedicated assistant test DB.
|
||||||
|
No test reads ASSISTANT_DATABASE_URL or an existing application schema.
|
||||||
|
"""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import os
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from apps.server.auth import Auth, password_hash, password_matches
|
||||||
|
from apps.server.db import PostgresStore, StoreConflict, StoreError
|
||||||
|
from apps.server.main import create_app
|
||||||
|
from apps.server.tests.test_learning import FakeWorker, initialize, working_candidate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pg_client(tmp_path):
|
||||||
|
database_url = os.environ.get("ASSISTANT_TEST_DATABASE_URL")
|
||||||
|
if not database_url:
|
||||||
|
pytest.skip("ASSISTANT_TEST_DATABASE_URL not provided; PostgreSQL tests are opt-in")
|
||||||
|
psycopg = pytest.importorskip("psycopg")
|
||||||
|
from psycopg import sql
|
||||||
|
schema = "assistant_learning_test_" + uuid4().hex
|
||||||
|
try:
|
||||||
|
app = create_app(tmp_path, FakeWorker(tmp_path), bootstrap_admin=False,
|
||||||
|
database_url=database_url, database_schema=schema)
|
||||||
|
with TestClient(app, base_url="http://127.0.0.1:8810") as client:
|
||||||
|
client.app_reference = app
|
||||||
|
client.worker_reference = app.state.browser
|
||||||
|
client.test_database_url = database_url
|
||||||
|
client.test_schema = schema
|
||||||
|
yield client
|
||||||
|
finally:
|
||||||
|
# The name is locally generated above and never reused by application
|
||||||
|
# configuration. Only this test-owned namespace may be removed.
|
||||||
|
with psycopg.connect(database_url, connect_timeout=5) as conn:
|
||||||
|
conn.execute(sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(sql.Identifier(schema)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_postgres_auth_learning_versions_validation_and_evidence(pg_client):
|
||||||
|
client = pg_client
|
||||||
|
initialize(client)
|
||||||
|
assert client.get("/api/health").json()["database_backend"] == "postgresql"
|
||||||
|
assert not client.app_reference.state.store.path
|
||||||
|
assert not (client.app_reference.state.store.home / "learning.sqlite").exists()
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
prefix = "/api/learning/candidates/" + candidate["id"]
|
||||||
|
first = client.post(prefix + "/check").json()
|
||||||
|
second = client.post(prefix + "/check").json()
|
||||||
|
assert first["status"] == second["status"] == "passed" and first["id"] != second["id"]
|
||||||
|
for keyword in ("建材", "装修"):
|
||||||
|
result = client.post(prefix + "/verify", json={"parameters": {"keyword": keyword}, "expected_outputs": {"results": keyword}})
|
||||||
|
assert result.status_code == 200 and result.json()["status"] == "passed"
|
||||||
|
mismatch = client.post(prefix + "/verify", json={"expected_outputs": {"results": "绝不会匹配"}})
|
||||||
|
assert mismatch.status_code == 200 and mismatch.json()["status"] == "failed"
|
||||||
|
detail = client.get("/api/learning/tasks/" + created["id"]).json()
|
||||||
|
assert len(detail["validations"]) == 5
|
||||||
|
assert detail["validations"][0]["id"] == mismatch.json()["id"]
|
||||||
|
assert client.get(detail["observations"][0]["screenshot_url"]).content.startswith(b"\x89PNG")
|
||||||
|
assert not client.post(prefix + "/publish-check").json()["ready"]
|
||||||
|
assert "ordinal" not in candidate and "ordinal" not in created
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
assert client.get("/api/learning/tasks").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_postgres_immutable_facts_and_concurrent_candidate_versions(pg_client):
|
||||||
|
client = pg_client
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
service = client.app_reference.state.learning
|
||||||
|
store = service.store
|
||||||
|
owner_id = store.one("SELECT id FROM operators")["id"]
|
||||||
|
client.post("/api/learning/candidates/" + candidate["id"] + "/check")
|
||||||
|
for table in ("observations", "learning_steps", "artifacts", "validations", "candidates"):
|
||||||
|
for operation in (f"DELETE FROM {table}", f"TRUNCATE {table} CASCADE"):
|
||||||
|
with pytest.raises(StoreConflict):
|
||||||
|
store.execute(operation)
|
||||||
|
with pytest.raises(StoreConflict):
|
||||||
|
store.execute("UPDATE candidates SET manifest_json='{}' WHERE id=$1", (candidate["id"],))
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
versions = list(pool.map(lambda _: service.create_candidate(created["id"], owner_id)["version"], range(4)))
|
||||||
|
assert sorted(versions) == [2, 3, 4, 5]
|
||||||
|
assert store.one("SELECT count(*) AS n FROM candidates")["n"] == 5
|
||||||
|
assert store.one("SELECT count(*) AS n FROM validations")["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_postgres_bootstrap_is_atomic_and_restart_preserves_changed_password(pg_client):
|
||||||
|
client = pg_client
|
||||||
|
store = client.app_reference.state.store
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
created = list(pool.map(lambda _: Auth(store).bootstrap_admin(), range(4)))
|
||||||
|
assert created.count(True) == 1
|
||||||
|
row = store.one("SELECT * FROM operators")
|
||||||
|
assert row["username"] == "admin" and password_matches("123456", row["password_hash"])
|
||||||
|
changed = password_hash("Changed-local-password-4831")
|
||||||
|
store.execute("UPDATE operators SET password_hash=$1 WHERE id=$2", (changed, row["id"]))
|
||||||
|
restarted = create_app(store.home, FakeWorker(store.home), database_url=client.test_database_url,
|
||||||
|
database_schema=client.test_schema)
|
||||||
|
assert restarted.state.store.one("SELECT password_hash FROM operators")["password_hash"] == changed
|
||||||
|
assert restarted.state.store.one("SELECT count(*) AS n FROM operators")["n"] == 1
|
||||||
|
status = client.get("/api/auth/status").json()
|
||||||
|
client.headers["X-CSRF-Token"] = status["csrf_token"]
|
||||||
|
assert client.post("/api/auth/login", json={"username": "admin", "password": "123456"}).status_code == 401
|
||||||
|
assert client.post("/api/auth/login", json={"username": "admin", "password": "Changed-local-password-4831"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_postgres_restart_and_database_diagnostics_remain_explicit(pg_client):
|
||||||
|
client = pg_client
|
||||||
|
initialize(client)
|
||||||
|
created, candidate = working_candidate(client)
|
||||||
|
prefix = "/api/learning/tasks/" + created["id"]
|
||||||
|
client.post("/api/learning/candidates/" + candidate["id"] + "/check")
|
||||||
|
client.post(prefix + "/browser/ready", json={"identity_label": "测试账号"})
|
||||||
|
store = client.app_reference.state.store
|
||||||
|
restarted = create_app(store.home, FakeWorker(store.home), database_url=client.test_database_url,
|
||||||
|
database_schema=client.test_schema)
|
||||||
|
with TestClient(restarted, base_url="http://127.0.0.1:8810") as second:
|
||||||
|
second.cookies.update(client.cookies)
|
||||||
|
second.headers["X-CSRF-Token"] = second.get("/api/auth/status").json()["csrf_token"]
|
||||||
|
detail = second.get(prefix).json()
|
||||||
|
assert detail["status"] == "paused" and not detail["browser_readiness"]["ready"]
|
||||||
|
assert len(detail["steps"]) == 3 and len(detail["validations"]) == 1
|
||||||
|
assert second.post("/api/learning/candidates/" + candidate["id"] + "/verify", json={"expected_outputs": {"results": "建材"}}).status_code == 409
|
||||||
|
with pytest.raises(StoreError) as caught:
|
||||||
|
store.one("SELECT private_sql_marker FROM nonexistent_private_table")
|
||||||
|
assert "private_sql_marker" not in str(caught.value)
|
||||||
|
assert client.test_database_url not in str(caught.value)
|
||||||
|
assert "PostgreSQL" in str(caught.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_postgres_schema_name_rejects_foreign_or_injected_names(tmp_path):
|
||||||
|
for schema in ("public", "learning_frontier", "assistant_learning;DROP SCHEMA public", "assistant_learning_" + "a" * 70):
|
||||||
|
with pytest.raises(StoreError, match="命名空间"):
|
||||||
|
PostgresStore(tmp_path, "postgresql://not-used.invalid/not-used", schema=schema)
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerError(Exception):
|
||||||
|
def __init__(self, message: str, code: str = "worker_error"):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserWorker:
|
||||||
|
"""Serialized RPC to the single browser owner; never imports the old application."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path, home: Path):
|
||||||
|
self.root, self.home = root, home
|
||||||
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
self.stderr_task: asyncio.Task | None = None
|
||||||
|
self.closing = False
|
||||||
|
self.close_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def _start(self) -> None:
|
||||||
|
if self.process and self.process.returncode is None:
|
||||||
|
return
|
||||||
|
node = os.environ.get("ASSISTANT_NODE") or shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
raise WorkerError("找不到 Node.js,请通过项目启动脚本运行后台", "node_not_found")
|
||||||
|
script = self.root / "tools" / "learning" / "browser-worker.mjs"
|
||||||
|
if not script.is_file():
|
||||||
|
raise WorkerError("本机学习执行器尚未就绪", "worker_unavailable")
|
||||||
|
environment = {**os.environ, "ASSISTANT_LEARNING_HOME": str(self.home)}
|
||||||
|
self.process = await asyncio.create_subprocess_exec(node, str(script), cwd=self.root, env=environment, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=8 * 1024 * 1024)
|
||||||
|
if self.closing:
|
||||||
|
await self._stop()
|
||||||
|
raise WorkerError("学习执行器正在正常退出", "worker_closed")
|
||||||
|
process = self.process
|
||||||
|
|
||||||
|
async def consume_stderr() -> None:
|
||||||
|
assert process.stderr
|
||||||
|
while await process.stderr.readline():
|
||||||
|
pass # Do not forward browser diagnostic text or URLs into business logs.
|
||||||
|
|
||||||
|
self.stderr_task = asyncio.create_task(consume_stderr())
|
||||||
|
|
||||||
|
async def call(self, method: str, params: dict) -> dict:
|
||||||
|
async with self.lock:
|
||||||
|
if self.closing:
|
||||||
|
raise WorkerError("学习执行器正在正常退出", "worker_closed")
|
||||||
|
await self._start()
|
||||||
|
process = self.process
|
||||||
|
assert process and process.stdin and process.stdout
|
||||||
|
request_id = uuid.uuid4().hex
|
||||||
|
try:
|
||||||
|
process.stdin.write((json.dumps({"id": request_id, "method": method, "params": params}, ensure_ascii=False) + "\n").encode())
|
||||||
|
await process.stdin.drain()
|
||||||
|
async with asyncio.timeout(120):
|
||||||
|
while True:
|
||||||
|
line = await process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise WorkerError("本机学习浏览器已退出,请重新打开会话", "worker_closed")
|
||||||
|
try:
|
||||||
|
response = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if response.get("id") != request_id:
|
||||||
|
continue
|
||||||
|
if response.get("error"):
|
||||||
|
error = response["error"]
|
||||||
|
raise WorkerError(str(error.get("message", "学习执行失败")), str(error.get("code", "worker_error")))
|
||||||
|
return response.get("result") or {}
|
||||||
|
except (TimeoutError, BrokenPipeError, ConnectionResetError):
|
||||||
|
await self._stop()
|
||||||
|
raise WorkerError("学习执行器响应中断;请核对页面与步骤后重新打开,不会自动重放动作", "worker_timeout") from None
|
||||||
|
|
||||||
|
async def _stop(self) -> None:
|
||||||
|
process, self.process = self.process, None
|
||||||
|
if not process:
|
||||||
|
return
|
||||||
|
if process.stdin:
|
||||||
|
process.stdin.close()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), 15)
|
||||||
|
except TimeoutError:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), 3)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
if self.stderr_task:
|
||||||
|
self.stderr_task.cancel()
|
||||||
|
self.stderr_task = None
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
# EOF must reach the child even while an RPC holds the request lock.
|
||||||
|
# Node closes its contexts on EOF, unblocking that exact active action.
|
||||||
|
self.closing = True
|
||||||
|
if self.close_task is None:
|
||||||
|
self.close_task = asyncio.create_task(self._stop())
|
||||||
|
await asyncio.shield(self.close_task)
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
name: assistant-learning
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: paradedb/paradedb:0.26.1-pg17
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: assistant
|
||||||
|
POSTGRES_PASSWORD: ${ASSISTANT_POSTGRES_PASSWORD:?Run pnpm db:up to create local database configuration}
|
||||||
|
POSTGRES_DB: assistant_learning
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${ASSISTANT_POSTGRES_PORT:-55432}:5432"
|
||||||
|
volumes:
|
||||||
|
- learning-pgdata:/var/lib/postgresql/data
|
||||||
|
- ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/20_assistant.sql:ro
|
||||||
|
shm_size: 256mb
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
learning-pgdata:
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Create validated architecture, workflow, sequence, data-flow, lifecycle/state, entity-relationship, class/interface, tree, timeline, and execution waterfall diagrams as explorable standalone HTML with inline SVG, dark/light themes, optional motion, and image/SVG/WebM export. Accept plain-language requirements or Mermaid; inspect repository evidence for code-backed diagrams. Use when visualizing infrastructure, cloud/security networks, API calls, data pipelines, ETL/ELT, state machines, database schemas, class contracts, module hierarchies, incident/release histories, or request and agent-run timings. Also use for everyday subjects with steps, parts, relationships, or states: leave and travel plans, applications and approvals, renting conversations, money or document flows, and order progress. Not for numeric charts or dashboards.
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
# assistant 开发进度
|
||||||
|
|
||||||
|
维护日期:2026-10-22,日期采用 Asia/Shanghai。
|
||||||
|
|
||||||
|
本文件记录实际进展与验证事实。优化方向、优先级及后续步骤统一维护在 [整体方案第 14 节](整体方案-v0.2.md#14-当前优化与近期实施规划)。后续每轮结束前追加记录;本次将此前会话中的工作按阶段补记,不虚构各轮发生时间。
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
- 已有 React/TypeScript 学习后台、FastAPI API、Node.js Playwright 工作进程。
|
||||||
|
- A2A 学习后台的适用页面与组件已裁剪迁入;原工程保持只读。
|
||||||
|
- 学习账本已接入独立 PostgreSQL 17。检索库准备 `pg_search 0.26.1`、`pgvector 0.8.6`;SQLite 保留作离线开发和测试适配,原数据及备份保留。
|
||||||
|
- 管理员在空账号库初始化为 `admin / 123456`,已有账号和密码不随启动重置。
|
||||||
|
- 已支持人工确认会话、采集证据、记录参数化步骤、生成不可变候选、静态校验、实际试跑及追加验证历史。
|
||||||
|
- 首期学习方式已确认为维护者与本地 Codex 协作:Codex 观察、编写、修订和验证,后台保留资料。未完成真实小红书学习,也未实现后台内置自主学习 Agent。
|
||||||
|
- 第一条目标链路为小红书「搜索笔记 → 详情 → 评论读取」。平台自动身份核验、完整独立来源读回、正式发布分发、Electron 商家端、macOS 实机验收及商业 AI/计量仍未完成。
|
||||||
|
- 最近已执行的工程验证为:后台套件 28 项通过、浏览器工作进程 12 项回归通过、PG + Edge 本机演示页端到端通过、检索扩展烟测通过。具体边界与证据见下文;这些阶段计数不累加,也不表示本轮重新执行。
|
||||||
|
- Git:当前基建代码与文档调整仍在本机工作区,尚未提交或推送;以实际 Git 操作结果更新。
|
||||||
|
|
||||||
|
## 2026-10-22-01:学习后台与浏览器基础(历史回填)
|
||||||
|
|
||||||
|
目标:先搭学习与验证基础,首条平台目标选小红书搜索、详情、评论读取。
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
|
||||||
|
- 建立 `apps/admin/`、`apps/server/`、`tools/learning/` 及本机启动/验证脚本。
|
||||||
|
- 从原 A2A 后台裁剪导航、学习批次、账本总览、能力实体、参数表、操作链详情、测试列表、历史及证据抽屉,接本项目 API;清单见 [迁入来源](../apps/admin/source-provenance.md)。
|
||||||
|
- 实现固定账号资料目录、同账号会话互斥、人工确认后观察、动作前后证据和参数绑定。重开后保留资料,但需重新确认当前会话。
|
||||||
|
- 实现不可变候选与证据摘要绑定、追加验证记录、发布条件预检。未接入完整独立来源读回或正式分发。
|
||||||
|
- 修复工作进程 EOF/输出管道关闭时的清理,以及测试端口与实例标识隔离。
|
||||||
|
|
||||||
|
阶段验证:TypeScript 检查与生产构建通过;该阶段后台套件 19 项通过,浏览器回归后续收敛为 12 项真实 Edge 无头测试。浏览器测试仅使用隔离本机页面,不涉及真实小红书账号;保留 Cookie、会话互斥、正确/错误预期和执行中退出均有覆盖。
|
||||||
|
|
||||||
|
证据:[浏览器测试脚本](../tools/learning/test-browser-worker.mjs)、[本机回归报告](../.tmp/worker-regression-S8WGVV/report.json)。报告属于忽略的本机临时资产,不随仓库提交,不能当作其他设备的验证结果。
|
||||||
|
|
||||||
|
未完成:真实平台适配、平台身份自动核验、后台自主学习、macOS 实测、发布分发。原 A2A 的多机调度、完整知识合同与恢复系统未整包迁入。
|
||||||
|
|
||||||
|
提交/推送:本阶段基建尚未提交或推送。
|
||||||
|
|
||||||
|
## 2026-10-22-02:管理员初始化(历史回填)
|
||||||
|
|
||||||
|
目标:按用户要求初始化 `admin / 123456`。
|
||||||
|
|
||||||
|
已完成:空账号库一次性创建管理员,密码使用随机盐 scrypt 摘要;并发初始化幂等,已有任意维护者或已修改密码保留。登录表单默认账号为 `admin`,README 同步说明。
|
||||||
|
|
||||||
|
涉及文件:[认证](../apps/server/auth.py)、[后台入口](../apps/server/main.py)、[登录界面](../apps/admin/src/App.tsx)、[初始化测试](../apps/server/tests/test_bootstrap.py)。
|
||||||
|
|
||||||
|
阶段验证:当时后台套件 23 项通过,实际本机登录返回成功,本机端到端也通过。该计数是后续 28 项套件之前的阶段快照。
|
||||||
|
|
||||||
|
未完成:本轮没有新增账号管理或改密页面。
|
||||||
|
|
||||||
|
提交/推送:未提交、未推送。
|
||||||
|
|
||||||
|
## 2026-10-22-03:PostgreSQL 与检索扩展(历史回填)
|
||||||
|
|
||||||
|
目标:学习后台使用 Docker PostgreSQL,并核对原项目的 `pg_search` / `pgvector` 与真实学习核心。
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
|
||||||
|
- 增加 [Compose 配置](../compose.yaml)、[初始化 SQL](../infra/postgres/init.sql) 和 `pnpm db:up/status/stop/check`;建立独立学习库、检索库和测试库。启动时实际健康检查通过,绑定本机 `55432` 端口,不据此推断当前持续在线。
|
||||||
|
- [存储层](../apps/server/db.py) 支持 PostgreSQL URL 与独立 schema、原生参数绑定、事务互斥、顺序及不可变事实保护;SQLite 适配保留。health 增加 `database_backend`。
|
||||||
|
- 增加 [迁移脚本](../scripts/migrate_learning.py):从一致备份导入空 PG 命名空间,同一事务核对数量,保留原 SQLite 和备份,非空目标拒绝覆盖。本次实际迁移 1 个维护者、2 条会话,无学习任务/候选/验证需要迁移;不声称已验证所有历史学习资料的迁移。
|
||||||
|
- 实际后台 health 返回 `postgresql`,迁移后的 `admin` 登录成功。
|
||||||
|
- 核对原工程:`pg_search` / `pgvector` 服务学习后的知识检索;原初次学习入口是独立 runner + Skills + 受限工具,部分 runner 与对象落盘实现仅适用 macOS,不能直接宣称 Win/Mac 完整迁移。
|
||||||
|
|
||||||
|
阶段验证:
|
||||||
|
|
||||||
|
- 后台套件 **28 passed,无跳过**,含 4 项真实 PG 用例;测试中的浏览器动作使用 `FakeWorker`,不属于真实平台验收。覆盖鉴权、候选并发、不可变事实、失败断言、密码保留和重启恢复。测试入口为 `pnpm test:server`。
|
||||||
|
- `pnpm test:learning` 在专用 PG 测试库与随机 schema 中,用真实 Edge 跑本机演示页;保存 23 份观察、5 个步骤、2 组成功参数试跑和总计 4 条验证记录,故意错误预期正确失败,重启保留记录,发布预检未放行。证据见 [本机端到端摘要](../.tmp/learning-acceptance-1792661264665/summary.json)。
|
||||||
|
- `pnpm db:check` 验证 PG17、`pg_search 0.26.1`、`vector 0.8.6`,中文 BM25、平台过滤、索引计划及固定样本向量排序通过;未调用真实 embedding 服务,未测语义召回质量。
|
||||||
|
- 方案图解七个标签页检查通过,未发现脚本错误或几何越界。
|
||||||
|
|
||||||
|
未完成:业务知识索引、真实 embedding、检索代际/发布资格衔接、自主学习、真实小红书和双端业务验收。完整规划见整体方案第 5.9、14 节。
|
||||||
|
|
||||||
|
提交/推送:未提交、未推送。
|
||||||
|
|
||||||
|
## 2026-10-22-04:确认本地 Codex 协作学习(历史回填)
|
||||||
|
|
||||||
|
目标:明确首期直接由本地 Codex 学习与固化。
|
||||||
|
|
||||||
|
已确认并写入方案:维护者登录 → 本地 Codex 观察与开发 → 提交候选 → 后台校验及变参试跑 → 根据证据修订;后台内置无人值守 Agent 后置。商家运行固化链路,内容理解/意图判断按需调用业务模型。
|
||||||
|
|
||||||
|
涉及文件:README、整体方案第 5.9 节与方案图解。
|
||||||
|
|
||||||
|
验证:文档差异检查通过;本轮为设计澄清,没有新增模型工具循环,也没有进行真实平台学习或重跑功能测试。
|
||||||
|
|
||||||
|
提交/推送:未提交、未推送。
|
||||||
|
|
||||||
|
## 2026-10-22-05:确认平台登录窗口(历史回填)
|
||||||
|
|
||||||
|
目标:明确小红书应在哪个浏览器登录。
|
||||||
|
|
||||||
|
已确认:Codex 右侧可以打开学习后台;平台登录在该后台「打开浏览器」启动的专用窗口完成,再回后台记录人工确认。日常 Chrome、Codex 右侧浏览器与该专用资料目录的登录态分别管理;资料持久化不保证平台永久免登录。
|
||||||
|
|
||||||
|
验证:此轮为操作说明,没有代码变更;尚未据此确认用户已登录,也没有采集真实小红书页面。
|
||||||
|
|
||||||
|
下一步:按 [近期规划](整体方案-v0.2.md#14-当前优化与近期实施规划) 完成首次实际观察与限定链路学习。
|
||||||
|
|
||||||
|
提交/推送:未执行。
|
||||||
|
|
||||||
|
## 2026-10-22-06:建立逐轮进度与规划维护约定
|
||||||
|
|
||||||
|
目标:每轮实际进度写入进度文档,优化与后续方向写入规划方案。
|
||||||
|
|
||||||
|
已完成:新增本进度文档并回填上述阶段;新增根 `AGENTS.md` 与持续生效的项目规则;README 增加入口;整体方案新增近期实施规划与维护约定,修正“尚无任何实测”的过时表述。
|
||||||
|
|
||||||
|
验证:本轮只修改文档和规则;执行本地链接与文档差异检查,不重复运行应用功能测试。历史 28 项套件及其他验收只作为历史事实引用。
|
||||||
|
|
||||||
|
未完成与下一步:真实小红书链路仍待学习,详见整体方案第 14 节。
|
||||||
|
|
||||||
|
提交/推送:未执行。
|
||||||
|
|
||||||
|
## 2026-10-22-07:提交并同步当前仓库(本轮)
|
||||||
|
|
||||||
|
目标:按用户指令,将当前学习后台基建、数据库配置模板、方案、图解、进度及项目规则同步到 GitHub。
|
||||||
|
|
||||||
|
本轮范围:`apps/`、`tools/learning/`、`scripts/`、Compose/初始化 SQL、依赖声明与锁文件,以及已有文档和项目规则;保留用户的 `docs/Skills.md` 内容并一并同步。
|
||||||
|
|
||||||
|
验证与检查:核对当前分支 `main`、远程 `zwt-cmd/assistant`、70 个暂存文件及忽略规则,远端无待合并更新;未发现真实凭据或私有连接串,`.env`、数据库、浏览器资料、依赖、构建和临时验收产物不进入提交。修正两处文件末尾多余空行,暂存差异检查通过,无功能变化;复用此前记录的功能验证结果,本轮不重复运行应用功能测试。
|
||||||
|
|
||||||
|
实施规划:本轮仅同步仓库,不改变整体方案第 14 节的优化优先级;下一步仍为本地 Codex 协作的小红书实际链路学习。
|
||||||
|
|
||||||
|
提交/推送:正在执行,成功后回填实际代码提交与远端核验结果。
|
||||||
|
|
@ -4,7 +4,17 @@
|
||||||
|
|
||||||
整理日期:2026 年 10 月 21 日
|
整理日期:2026 年 10 月 21 日
|
||||||
|
|
||||||
状态:已确认产品范围与建议实施路线,尚未进入功能开发。已确认事项、实施建议和待验证选择在第 13 节分别记录,不代表功能已经实现或通过测试。
|
最近更新:2026 年 10 月 22 日,参考 A2A 学习端,将已有的参数化、验证和版本管理细化为有界学习闭环。
|
||||||
|
|
||||||
|
本轮补充:学习端使用独立验证记录库;平台账号由商家在官方页面自行登录,按账号复用本地浏览器资料并核验会话。
|
||||||
|
|
||||||
|
工程实施补充:`browser_use` 的桌面壳等仍按第 4.4 节评估;根据当前实施决定,学习后台以 `D:\qiekj\PenguinUniOS-Workspace\projects\learning` 的适用模块为基础裁剪,接入本项目独立的 PostgreSQL 与 Playwright 工作进程,见第 5.8 节。
|
||||||
|
|
||||||
|
状态:已进入本机学习与验证后台基建。方案中的商家桌面端、真实平台适配、AI 与商业模块仍是后续工作,不能将后台基础功能视为整个获客产品完成。已确认事项、实施建议和待验证选择在第 13 节分别记录。
|
||||||
|
|
||||||
|
维护约定:每轮实际进展与验证结果追加到 [开发进度](开发进度.md);优化、设计决定和优先级维护在本方案,近期实施顺序见第 14 节。进度文档记录事实,不用规划替代完成证据。
|
||||||
|
|
||||||
|
交互图解:[方案图解](方案图解.html),包含系统架构、获客流程、学习发布、AI 与额度、任务恢复、登录会话和实施阶段。设计调整时同步维护文字与图解。
|
||||||
|
|
||||||
## 1. 建议采用的整体路线
|
## 1. 建议采用的整体路线
|
||||||
|
|
||||||
|
|
@ -12,11 +22,11 @@
|
||||||
|
|
||||||
桌面端负责账号登录、浏览器操作、任务运行和线索管理;学习工具负责把平台操作变成经过验证的能力包;云端负责模型调用、软件权益、订单和额度结算。
|
桌面端负责账号登录、浏览器操作、任务运行和线索管理;学习工具负责把平台操作变成经过验证的能力包;云端负责模型调用、软件权益、订单和额度结算。
|
||||||
|
|
||||||
第一版明确同时适配 Windows 和 macOS,两端按同一业务范围开发、验证和发布。先验证抖音网页版和建材场景;正式首发内容平台取决于网页版所需功能的验证结果。小红书、快手和手机执行端放在后续扩展阶段。
|
第一版明确同时适配 Windows 和 macOS,两端按同一业务范围开发、验证和发布。第一条学习链路已选定小红书「搜索笔记 → 详情 → 评论读取」;先验证本机演示页的基础链路,再由维护者在官方页面自行登录后进行实际适配。正式首发平台取决于验证结果,抖音、快手及手机执行端后续分别评估。
|
||||||
|
|
||||||
桌面框架采用 Electron,界面建议采用 React + TypeScript。浏览器侧以 Playwright 固定执行为主线,围绕当前产品需要设计能力契约、定位、等待和状态核验。A2A 与 browser_use 可作为设计和实现参考,不设优先复用要求。Midscene 是可选辅助组件,在出现明确定位或视觉需求后再对比引入,不作为首版必选依赖。
|
桌面框架采用 Electron,界面采用 React + TypeScript。浏览器侧以 Playwright 固定执行为主线,围绕当前产品需要设计能力契约、定位、等待和状态核验。当前已决定采用 A2A 学习后台的适用模块;其他 A2A 与 browser_use 资产仍按需选择。Midscene 是可选辅助组件,在出现明确定位或视觉需求后再对比引入,不作为首版必选依赖。
|
||||||
|
|
||||||
本地存储建议采用 SQLite,新增执行器和服务端建议采用 Node.js/TypeScript,云端数据库建议采用 PostgreSQL。实现选择以当前需求、验证效果和双端分发维护成本为依据;不把保留、迁移或重写其他项目作为本项目的任务前提。具体依赖版本在原型通过后固定。
|
本地存储建议采用 SQLite,新增执行器建议采用 Node.js/TypeScript,云端数据库建议采用 PostgreSQL。云端可采用现有测试工程的 FastAPI 认证与管理骨架,再新增本项目的 AI、权益与计量模块;这项选择在基础模块提取验证后确定,不再将服务端统一使用 TypeScript 作为建议前提。实现选择以当前需求、验证效果和双端分发维护成本为依据;具体依赖版本在原型通过后固定。
|
||||||
|
|
||||||
模型由我们在运营后台按用途配置,客户端不绑定具体厂商或版本。基础分析优先验证较低成本模型,复杂复核和视觉操作分别配置,均有独立预算。
|
模型由我们在运营后台按用途配置,客户端不绑定具体厂商或版本。基础分析优先验证较低成本模型,复杂复核和视觉操作分别配置,均有独立预算。
|
||||||
|
|
||||||
|
|
@ -25,7 +35,7 @@
|
||||||
## 2. 商家使用过程
|
## 2. 商家使用过程
|
||||||
|
|
||||||
1. 安装软件,登录我们平台的产品账号。
|
1. 安装软件,登录我们平台的产品账号。
|
||||||
2. 添加平台账号,在软件管理的浏览器中自行登录抖音等平台。
|
2. 添加平台账号,在软件打开的独立浏览器中自行扫码,或在平台官方页面输入手机号和验证码完成登录。软件核验登录状态和账号后保存本地账号映射;再次运行先检查原会话,失效时再请商家登录。
|
||||||
3. 创建商品档案:商品、使用场景、服务地区、客户类型和排除条件;未设置的地区等条件不自动补齐。
|
3. 创建商品档案:商品、使用场景、服务地区、客户类型和排除条件;未设置的地区等条件不自动补齐。
|
||||||
4. 配置搜索关键词或推荐流入口、任务范围、预算及跟进方式。
|
4. 配置搜索关键词或推荐流入口、任务范围、预算及跟进方式。
|
||||||
5. 启动任务,查看候选视频、主页判断、评论线索和筛选依据。
|
5. 启动任务,查看候选视频、主页判断、评论线索和筛选依据。
|
||||||
|
|
@ -64,8 +74,12 @@ flowchart TD
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph DEV[维护者环境]
|
subgraph DEV[维护者环境]
|
||||||
LEARN[学习与调试工具] --> CHECK[参数化与回归验证]
|
LEARN[限定目标并观察取证] --> KNOW[字段、状态、分支与缺口]
|
||||||
CHECK --> PKG[版本化能力包]
|
KNOW --> CANDIDATE[候选能力与验证用例]
|
||||||
|
CANDIDATE --> CHECK[固定规则校验]
|
||||||
|
CHECK --> VERIFY[变参试跑与结果核对]
|
||||||
|
VERIFY --> PKG[发布不可变能力版本]
|
||||||
|
VERIFY -. 验证失败后修订 .-> CANDIDATE
|
||||||
end
|
end
|
||||||
subgraph LOCAL[商家电脑]
|
subgraph LOCAL[商家电脑]
|
||||||
UI[Electron 商家界面] --> RUN[任务与链路执行器]
|
UI[Electron 商家界面] --> RUN[任务与链路执行器]
|
||||||
|
|
@ -85,6 +99,7 @@ flowchart LR
|
||||||
end
|
end
|
||||||
PKG --> RELEASE
|
PKG --> RELEASE
|
||||||
RELEASE --> RUN
|
RELEASE --> RUN
|
||||||
|
RUN -. 失败证据回流维护端 .-> LEARN
|
||||||
UI --> API
|
UI --> API
|
||||||
RUN --> AI
|
RUN --> AI
|
||||||
MS --> AI
|
MS --> AI
|
||||||
|
|
@ -96,7 +111,9 @@ flowchart LR
|
||||||
|
|
||||||
桌面采用 Electron,界面建议使用 React + TypeScript,Windows 和 macOS 共享界面、任务、平台链路及 AI 客户端代码;应用目录、系统凭据存储、进程管理、菜单快捷键、安装与升级通过小范围系统适配处理。界面用于配置、查看和接管;浏览器任务放到独立执行进程,避免耗时操作阻塞界面。新增执行代码建议采用 Node.js/TypeScript,界面通过受限 IPC 调用任务能力。只有某个外部组件有明确价值并通过验证时,才按需增加其他运行时和进程适配。
|
桌面采用 Electron,界面建议使用 React + TypeScript,Windows 和 macOS 共享界面、任务、平台链路及 AI 客户端代码;应用目录、系统凭据存储、进程管理、菜单快捷键、安装与升级通过小范围系统适配处理。界面用于配置、查看和接管;浏览器任务放到独立执行进程,避免耗时操作阻塞界面。新增执行代码建议采用 Node.js/TypeScript,界面通过受限 IPC 调用任务能力。只有某个外部组件有明确价值并通过验证时,才按需增加其他运行时和进程适配。
|
||||||
|
|
||||||
第一版建议控制独立、可见的浏览器窗口,不先建设复杂的内嵌浏览器画面和远程桌面。Electron 的界面窗口和被自动化控制的平台浏览器是两部分。
|
第一版建议控制独立、可见、可最大化的浏览器窗口。商家登录时像使用普通浏览器一样操作,登录完成后回到软件控制台。Electron 的界面窗口和被自动化控制的平台浏览器是两部分;后续若要将平台画面嵌入软件内容区,需要另行验证会话和执行器接入。
|
||||||
|
|
||||||
|
现有测试工程已实现 WebContentsView 宿主、面板布局和人工接管,因此将内嵌浏览器列为首版可验证的替代方案。先验证它与本项目固定执行器的接入、官方页面人工登录、账号会话复用、收起及截图行为,再决定是否采用;验证前保留独立窗口作为当前主线。采用其桌面壳不要求同时采用内嵌宿主、Python 本地后端或 browser-use Agent。
|
||||||
|
|
||||||
该进程划分参考 [Electron 官方进程模型](https://www.electronjs.org/docs/latest/tutorial/process-model)。打包、进程退出和浏览器生命周期都需要在 Windows 与 macOS 安装包中分别验证。
|
该进程划分参考 [Electron 官方进程模型](https://www.electronjs.org/docs/latest/tutorial/process-model)。打包、进程退出和浏览器生命周期都需要在 Windows 与 macOS 安装包中分别验证。
|
||||||
|
|
||||||
|
|
@ -108,6 +125,18 @@ flowchart LR
|
||||||
|
|
||||||
固定执行层负责打开页面、点击、输入、滚动、等待和读取,首选 Playwright,按本项目的能力契约封装与验证。一个运行会话明确由一个驱动持有。每个平台账号绑定独立的浏览器数据目录,保留其登录状态。产品账号、平台账号和浏览器目录分别建模。
|
固定执行层负责打开页面、点击、输入、滚动、等待和读取,首选 Playwright,按本项目的能力契约封装与验证。一个运行会话明确由一个驱动持有。每个平台账号绑定独立的浏览器数据目录,保留其登录状态。产品账号、平台账号和浏览器目录分别建模。
|
||||||
|
|
||||||
|
**登录交互。** 商家在软件管理的浏览器中打开平台官方页面,自行扫码或输入手机号、验证码等登录信息。我们的产品账号界面不索取这些平台登录信息,也不代收验证码。登录阶段业务执行器暂停动作,商家持有操作权;登录判定只读取必要的已登录页面标志及账号标识。手机号、验证码和密码不进入业务数据库、日志、学习证据或模型输入。
|
||||||
|
|
||||||
|
**登录成功的判据。** 等待平台完成登录跳转,并检查可用的已登录页面标志和可靠账号标识,绑定到本地账号记录。二维码消失、登录弹窗关闭、已有 Cookie 或数据库上次记为已登录,都不能单独证明当前账号有效。身份不明、与任务预期账号不一致或仍需验证时,保持等待处理。具体判据按平台学习、验证和维护。
|
||||||
|
|
||||||
|
**登录后的窗口。** 首选在同一浏览器会话中从人工操作移交给执行器,登录完成后回到控制台;可最小化浏览器继续执行,提供重新显示和接管入口。最小化对视频加载、读取和任务推进的影响在 Windows/macOS 分别验证;未验证前保留可见执行方式。关闭整个浏览器会结束当前执行会话,运行端应暂停任务并保存检查点,不能把关闭窗口视为浏览器仍在执行。首版不将可见登录后自动切换无头运行作为默认流程。
|
||||||
|
|
||||||
|
**下次如何复用。** 每个账号分配固定的专用资料目录,使用 Playwright 的持久化上下文打开同一目录,保存浏览器支持持久化的 Cookie 和本地存储等资料。目录位于应用本地数据区,与安装目录分开;正常重启、任务结束和客户端升级保留目录。SQLite 保存账号标识、目录引用、会话状态和最后核验时间,平台登录资料由浏览器管理。
|
||||||
|
|
||||||
|
如采用内嵌浏览器,由 Electron 的稳定 `persist:` 会话分区保存资料,执行器连接该宿主的实际页面目标;上层仍按本地平台账号映射会话引用。独立浏览器目录和 Electron 分区是两种存储实现,不把登录页放在一个会话、执行页另建到空白会话,也不依赖复制两种实现的 Cookie 来切换。现有测试工程在新建会话时追加随机标识,需改为稳定的账号作用域,并保留身份核验、控制权与账号互斥规则。
|
||||||
|
|
||||||
|
新任务先复用已有浏览器会话;需要重开时先正常关闭并释放原会话,再用同一目录启动,打开页面核验账号和登录有效性。资料保留且平台会话有效时可直接执行,不需要每次重新扫码。平台会话到期、退出登录、资料清理或平台要求重新验证时,进入等待登录;不承诺永久免登录,也不设未经实测的有效天数。不同设备及 Windows/macOS 各自建立和验证会话,不将资料目录直接复制作为登录迁移方案。
|
||||||
|
|
||||||
一个平台账号同一时间由一个执行任务持有,暂停接管时移交控制权,避免人和执行器同时操作。同一数据目录不得被多个浏览器实例同时占用。
|
一个平台账号同一时间由一个执行任务持有,暂停接管时移交控制权,避免人和执行器同时操作。同一数据目录不得被多个浏览器实例同时占用。
|
||||||
|
|
||||||
首版可先运行一个平台账号,但数据结构从开始就保留账号标识。登录失效进入等待登录状态,完成登录并核验身份后再继续。
|
首版可先运行一个平台账号,但数据结构从开始就保留账号标识。登录失效进入等待登录状态,完成登录并核验身份后再继续。
|
||||||
|
|
@ -116,48 +145,108 @@ flowchart LR
|
||||||
|
|
||||||
持久化目录与连接能力以 [Playwright 官方 BrowserType 文档](https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context)为依据。首版使用专用目录,不依赖复制商家日常浏览器的整份资料。
|
持久化目录与连接能力以 [Playwright 官方 BrowserType 文档](https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context)为依据。首版使用专用目录,不依赖复制商家日常浏览器的整份资料。
|
||||||
|
|
||||||
|
登录资料复用仍需按真实平台验证;可复用状态与失效处理参考 [Playwright 身份状态文档](https://playwright.dev/docs/auth)。保存目录不保证所有内存或临时会话状态在重启后可用,重新打开后的有效性检查始终保留。
|
||||||
|
|
||||||
### 4.3 本地与云端的数据边界
|
### 4.3 本地与云端的数据边界
|
||||||
|
|
||||||
本地 SQLite 保存平台账号映射、任务、检查点、候选内容、线索、证据及动作结果。浏览器登录资料保留在对应本地目录。
|
商家端独立的业务 SQLite 保存平台账号映射、任务、检查点、候选内容、线索、证据及动作结果。账号映射包含浏览器目录或会话分区引用和最后核验状态;平台登录资料保留在对应本地浏览器资料中。维护者学习端使用独立 PostgreSQL 学习库和检索库,两端分别管理生命周期和数据,不共享商家登录资料。
|
||||||
|
|
||||||
云端 PostgreSQL 保存产品账号、设备权益、订单、额度账本、模型请求计量以及版本清单。模型分析按任务提交所需文本或图片。
|
云端 PostgreSQL 保存产品账号、设备权益、订单、额度账本、模型请求计量以及版本清单。模型分析按任务提交所需文本或图片。
|
||||||
|
|
||||||
第一版以本地线索为主,不默认建设全量线索同步。网页后台先服务产品管理、订单、用量和发布;如果后续要在手机上查看线索,再增加明确的线索同步功能。
|
第一版以本地线索为主,不默认建设全量线索同步。网页后台先服务产品管理、订单、用量和发布;如果后续要在手机上查看线索,再增加明确的线索同步功能。
|
||||||
|
|
||||||
|
学习维护所需的失败回流,首版通过诊断导出与维护端导入实现,内容限定为相关能力版本、必要输入、失败位置及对应页面证据片段。诊断资料和业务线索同步分别设计,浏览器登录资料仍保留在本地账号目录。
|
||||||
|
|
||||||
供应商模型密钥保存在服务端。客户端通过我们自己的 AI 网关调用模型,网关负责身份、模型路由、额度和请求预算。模型配置发布后有明确版本;客户端按业务用途调用,服务端记录实际使用的配置与模型。若引入 Midscene 或 browser_use 的模型调用,也接入这层网关,分别验证协议及模型适配兼容性。
|
供应商模型密钥保存在服务端。客户端通过我们自己的 AI 网关调用模型,网关负责身份、模型路由、额度和请求预算。模型配置发布后有明确版本;客户端按业务用途调用,服务端记录实际使用的配置与模型。若引入 Midscene 或 browser_use 的模型调用,也接入这层网关,分别验证协议及模型适配兼容性。
|
||||||
|
|
||||||
|
### 4.4 测试工程可以采用哪些基础
|
||||||
|
|
||||||
|
2026 年 10 月 22 日只读核对 `D:\qiekj\browser_use` 当前工作区的源码、路由、数据结构、打包脚本与历史验证记录。该工程已有大量未提交修改,本次结论针对当前工作区;源码存在和历史通过记录不代表本轮重新验收通过。未修改源工程,未启动其服务、任务、数据库或平台账号。
|
||||||
|
|
||||||
|
**候选采用范围。**
|
||||||
|
|
||||||
|
- 桌面壳:`electron/` 的窗口、应用协议、preload 与 IPC、就绪检查、产品账号登录及刷新生命周期;`renderer/` 的布局、任务状态、历史、接管和通用展示组件。原后端进程管理接口可参考,启动对象改为本项目执行进程。
|
||||||
|
- 管理界面:`console/` 的布局、主题、登录页、菜单权限、用户管理、审计、筛选与通用表格组件。商品、线索、用量和商业管理页面按本项目新增。
|
||||||
|
- 云端骨架:`server/` 的 FastAPI 应用、产品用户与会话、权限和审计,以及配置草稿、修订与发布机制。可保留 FastAPI/PostgreSQL;学校业务 JSON 需要替换为本项目配置与能力清单契约。
|
||||||
|
- 浏览器宿主:WebContentsView、人工接管、输入隔离及目标检查作为独立候选组件,按第 4.1、4.2 节验证,避免把浏览器展示层与原 Agent 编排整套绑定。
|
||||||
|
|
||||||
|
**必须适配的边界。**
|
||||||
|
|
||||||
|
1. 学校、学生、业务系统与成员关系贯穿原表结构、接口和 UI,需要映射为商家、产品用户、内容平台及本地平台账号模型;不能仅替换文案。原同一用户、同一系统唯一绑定也需要适配同平台多账号。
|
||||||
|
2. 原业务登录依赖用户名、加密密码、角色绑定和受控凭据证据。采用产品账号认证时,将第三方平台登录单独改为官方页面人工登录、本地持久化会话和账号身份核验,不携带原密码代配与凭据租约链。
|
||||||
|
3. 原 runner 在新建内嵌分区及独立浏览器目录时加入随机标识,不能直接提供本项目按账号跨任务复用登录态。采用宿主后需稳定账号映射;同一活跃会话保活与重开后复用分别验收。
|
||||||
|
4. 原 `backend/` 本地服务与中心 `server/` 均使用 PostgreSQL。桌面业务存储仍按本项目 SQLite 设计,学习端仍使用独立验证库;提取基础时切开本地 PG/Docker 和校园编排依赖。
|
||||||
|
5. 原本地模型设置、供应商密钥与兜底调用,不等同于本项目云端按用途配置、预算和计费网关。客户端模型配置界面需替换;设备登录标识也不等同于商业设备权益。
|
||||||
|
6. 原打包入口只配置 Windows 免安装目录,已有非 Windows 路径分支不能视为 macOS 发布验证。本项目仍需完成目标系统与架构验证、安装包、签名、macOS 公证和升级回滚。
|
||||||
|
|
||||||
|
**继续新增的产品能力。** 获客业务页面与平台链路、商家业务和学习验证存储、独立能力包契约与门禁、订阅套餐、订单与续费、设备权益、AI 网关、模型配置版本、额度预占结算及对账仍需建设。本次未在原中心路由和表结构中发现完整商业模块实现。
|
||||||
|
|
||||||
|
**建议提取顺序。** 先在本项目建立独立应用标识、数据目录、端口与构建入口,提取桌面壳和后台布局,再适配产品认证与商家模型,接入 SQLite 与固定执行器,随后增加 AI 与商业模块。内嵌浏览器只作限定范围验证后决定采用。原工程含端口探测及关闭旧本地服务逻辑,新应用须校验进程归属,不能沿用旧项目端口或关闭其运行实例。提取范围限选定源码、必要资源、依赖声明和相应测试;不复制原环境配置、账号资料、运行数据、依赖目录、构建产物或 Git 元数据。
|
||||||
|
|
||||||
## 5. 学习端怎样落地
|
## 5. 学习端怎样落地
|
||||||
|
|
||||||
### 5.1 谁负责学习
|
### 5.1 谁负责学习
|
||||||
|
|
||||||
首版由我们在维护者环境中学习、调试和验证平台链路,然后发布给商家使用。同一版本的共性平台操作可以复用;商家的关键词、商品和客户条件作为运行参数。
|
首版由我们在维护者环境中学习、调试和验证平台链路,然后发布给商家使用。同一版本的共性平台操作可以复用;商家的关键词、商品和客户条件作为运行参数。
|
||||||
|
|
||||||
学习端从开发工具做起:观察真实操作、记录页面状态、生成或编写脚本、参数化、运行样本、查看结果。不要求第一版就做成商家能自行学习任意网站的完整通用产品。
|
学习端从开发工具做起,采用“限定范围、观察取证、整理操作知识、生成候选、固定校验、变参试跑、发布版本”的闭环。维护者可以手写、借助模型生成或修订实现;各类实现经过相同的校验和验证。首版不要求自动学习任意网站,也不要求每个学习步骤都由模型完成。
|
||||||
|
|
||||||
### 5.2 学习的产物
|
每次学习先明确平台、能力目标、页面入口、账号角色、输入输出、需要覆盖的正常与异常分支,以及时间、步骤和模型调用预算。一次学习未覆盖的情况记录为缺口,不能把“见过页面”“理解操作”和“执行验证通过”合并成一个成功状态。
|
||||||
|
|
||||||
每条能力包至少包括:能力名称、内容平台、版本、输入输出定义、前置条件、动作实现、等待与完成判据、错误状态、恢复规则、操作系统与浏览器兼容范围和验证样本。发布清单记录 Windows 与 macOS 各自的验证状态,不能把单端通过视为双端通过。
|
### 5.2 从页面证据到可执行能力
|
||||||
|
|
||||||
例如,搜索能力输入关键词和读取范围,输出候选视频与来源;评论能力输入视频标识和进度,输出本轮评论、父子关系、来源及下一读取状态。
|
1. **观察并留存依据。** 记录页面和当前对象、操作前后状态、动作、读取值、定位候选及匹配数量、等待条件、结果和观察时间。截图或页面快照作为可回看的依据,具体保存范围受本次学习预算限制。
|
||||||
|
2. **整理结构化操作知识。** 明确输入字段的类型、必填条件和值来源,记录页面状态、步骤先后、分支条件、预期效果及未理解项。例如搜索的有结果、无结果、需要登录,评论的继续加载、展开回复和读取结束分别描述。
|
||||||
|
3. **形成候选能力及用例。** 将确定的步骤变成参数化实现,附输入输出契约、结果核对方式、验证用例和证据引用。模型的文字说明、点击录像或 Midscene 缓存都不能直接作为已发布能力。
|
||||||
|
4. **固定规则校验。** 由程序检查结构与类型、参数绑定、动作范围、定位与等待定义、输出映射、用例覆盖和证据引用。校验拒绝先返回候选修订;这一步不能代替真实浏览器验证,也不由模型自行宣告通过。
|
||||||
|
5. **变参试跑并核对结果。** 在维护环境安装候选版本,改变关键词、视频或账号对象、列表长度和页面状态,核对实际对象、字段、来源、分支及结束条件。存在多个匹配元素时明确作用域并核验目标,不能默认点击第一个。内容本身会变化,不要求两次返回完全相同的视频列表。
|
||||||
|
6. **发布经过验证的版本。** 只有满足该版本覆盖范围和双端验收要求的候选才进入发布清单;范围外情况保留缺口或明确不支持。更新生成新版本,旧版本及其验证记录保留。
|
||||||
|
|
||||||
|
“校验通过”表示产物结构符合约定;“试跑通过”表示指定环境与样本中实际执行并核对成功;“已发布”表示进入商家可用清单。这些状态分别记录。学习资料另记已观察范围、理解情况和剩余缺口,避免用单一进度百分比代替覆盖情况。
|
||||||
|
|
||||||
|
### 5.3 学习产物与最小存储
|
||||||
|
|
||||||
|
每条能力包至少包括:能力名称、内容平台、版本与内容校验值、输入输出定义、前置状态、参数化动作实现、定位与等待规则、完成判据、错误与未知结果状态、恢复规则、证据引用、验证用例、操作系统与浏览器兼容范围。发布清单关联验证结果,记录 Windows 与 macOS 各自的状态,不能把单端通过视为双端通过。
|
||||||
|
|
||||||
|
首版可以采用显式 TypeScript 实现加 JSON 清单,不先建设通用流程语言或工作流平台。学习记录与能力包分开:学习记录解释如何得到实现、还有哪些缺口,商家执行包只携带运行所需的契约、实现和兼容信息。
|
||||||
|
|
||||||
|
维护端保存学习任务、观察证据、操作知识与缺口、候选版本、校验与试跑记录、发布清单。学习后台采用独立 PostgreSQL 学习库,截图、快照等大文件保存为文件并由记录引用。检索库与学习账本分库,准备 pg_search 与 pgvector;商家桌面业务库仍采用 SQLite。SQLite 学习原型保留作离线开发及隔离测试,原库迁移后保留。首版不要求先建设多机调度或全站探索队列。
|
||||||
|
|
||||||
|
每次验证单独追加一条记录,至少包含学习任务、能力版本及内容校验值、用例和参数快照、平台与测试账号引用、操作系统与浏览器版本、执行器及必要模型配置版本、开始结束时间、前置与结束状态、预期效果、实际读回、校验或执行结论、失败原因、耗时和模型消耗,以及证据引用。未使用模型时可记录零调用;未取得实际计量时标记未知,不能用零替代。
|
||||||
|
|
||||||
|
固定校验、浏览器试跑、结果核对与双端验收分别记录并关联同一候选版本。重跑、修订回归产生新记录,保留历史结论;验证界面可按能力、版本、用例和系统筛选,查看预期与实际结果及关联证据。发布清单引用具体通过记录,同时保留未覆盖项;记录库中不保存平台手机号、验证码、密码或原始登录凭据。
|
||||||
|
|
||||||
|
例如,搜索能力输入关键词和读取范围,输出候选视频标识、标题、作者、来源及本轮读取状态;评论能力输入视频标识和进度,输出本轮评论、父子关系、来源及下一读取状态。验证时既检查数据结构,也检查读出的字段是否属于当前视频和评论对象。
|
||||||
|
|
||||||
固化的是“如何找到并处理当前对象”,不是把一次录制中的第几个按钮或屏幕坐标照搬到所有任务中。动态评论列表需要每次重新识别当前内容;DOM 句柄不能作为跨运行的长期身份。
|
固化的是“如何找到并处理当前对象”,不是把一次录制中的第几个按钮或屏幕坐标照搬到所有任务中。动态评论列表需要每次重新识别当前内容;DOM 句柄不能作为跨运行的长期身份。
|
||||||
|
|
||||||
### 5.3 首批学习范围
|
### 5.4 学习范围与运行判断的分工
|
||||||
|
|
||||||
首批沿用六类链路:搜索、视频读取与切换、主页与作品读取、评论与回复读取、关注、私信。跟进链路把准备、动作和结果核对分开。
|
首批沿用六类链路:搜索、视频读取与切换、主页与作品读取、评论与回复读取、关注、私信。先完成前四类的只读流程,关注、私信按跟进阶段验证。跟进链路把准备、动作和结果核对分开,试跑使用明确的测试账号和测试对象。
|
||||||
|
|
||||||
运行端只需要按业务规则编排这些能力。第一版不引入完整 A2A 的通用能力目录、多 Agent 协商和全域任务规划。
|
学习单元是可复用的平台操作及其必要分支。一条视频、一条评论和一个潜在客户是运行时处理的业务对象,不为每个对象新建一条需要学习的能力,也不遍历整个平台后才开始运行。
|
||||||
|
|
||||||
### 5.4 现有项目与开源项目的角色
|
运行端按业务规则调用已发布能力;内容相关性、账号角色和采购意图仍结合当前商品及上下文在运行时判断。可以固定读取与调用步骤,不能把一次学习中的客户判断冻结到所有商家和所有新内容上。
|
||||||
|
|
||||||
- **A2A 学习端及 browser_use:** 可参考能力包、参数化、定位、等待、会话和结果核验的设计与实现,不设优先复用、资产盘点或迁移要求。具体代码或组件只有适合当前需求时才考虑采用;其他业务验证不能等同于获客平台适配完成。
|
### 5.5 失败怎样回到学习端
|
||||||
|
|
||||||
|
区分候选校验拒绝、试跑失败、商家运行异常和动作结果未知。登录失效、额度耗尽等由既定运行状态处理;页面结构变化或未覆盖分支则回流维护端,携带能力版本、输入、相关证据和失败位置。
|
||||||
|
|
||||||
|
维护端在限定预算内定位原因,修订候选,并重新执行固定校验、受影响用例及必要回归,形成新版本。运行任务固定旧版本,不能让诊断模型在商家任务中边探索边替换生产脚本。发布后,新任务使用新版本;旧任务可恢复时仍使用原版本。原版本无法继续而需要新版本时,先检查检查点兼容性,再建立关联原任务的新任务,并继承已完成对象和动作账目,避免重新跟进。
|
||||||
|
|
||||||
|
关注或私信等操作结果未知时,先核对实际效果,再决定后续处理。不能为了生成失败样本或验证修复而重复发送;候选校验拒绝也不能记成浏览器执行失败。某次试跑通过不代表完整的自然失败、修订、回归闭环已被验证,验收记录分别说明覆盖情况。
|
||||||
|
|
||||||
|
### 5.6 现有项目与开源项目的角色
|
||||||
|
|
||||||
|
- **browser_use 测试工程的桌面与后台:** 本轮已检查其工程基础,按第 4.4 节作为候选提取源;这与采用 browser-use Python Agent 框架分别决定。优先减少窗口、产品认证和管理界面的重复建设,业务执行和存储按本项目契约重接。
|
||||||
|
- **A2A 学习端:** 当前决定以其现有学习后台的适用页面和工作流为基础裁剪,迁移与差异见第 5.8 节。能力包、参数化、定位、等待、会话和结果核验按本项目边界适配;原项目的业务验证不能等同于获客平台适配完成。browser_use Agent 的采用仍按需决定。
|
||||||
- **Midscene:** 作为可选的探索页面、局部定位、视觉读取和诊断组件,出现明确需求后,验证其效果、成本与集成价值。其规划与定位缓存可以减少调用,但查询和断言仍可能调用模型,不能直接当作免模型的已发布能力包。参见 [缓存机制](https://midscenejs.com/caching)和 [Playwright 集成](https://midscenejs.com/integrate-with-playwright)。
|
- **Midscene:** 作为可选的探索页面、局部定位、视觉读取和诊断组件,出现明确需求后,验证其效果、成本与集成价值。其规划与定位缓存可以减少调用,但查询和断言仍可能调用模型,不能直接当作免模型的已发布能力包。参见 [缓存机制](https://midscenejs.com/caching)和 [Playwright 集成](https://midscenejs.com/integrate-with-playwright)。
|
||||||
- **OpenAdapt Flow:** 重点研究录制、编译、参数化和执行后检查。当前开发位于 [OpenAdapt 主仓库的 flow 包](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/flow)。首版先参考产物设计,验证通过后再决定是否接入 Python 运行时。
|
- **OpenAdapt Flow:** 重点研究录制、编译、参数化和执行后检查。当前开发位于 [OpenAdapt 主仓库的 flow 包](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/flow)。首版先参考产物设计,验证通过后再决定是否接入 Python 运行时。
|
||||||
- **Riot、Browser Use Desktop、Workflow Use:** 保留为实现参考与对照原型,暂不作为首版同时引入的运行依赖。
|
- **Riot、Browser Use Desktop、Workflow Use:** 保留为实现参考与对照原型,暂不作为首版同时引入的运行依赖。
|
||||||
|
|
||||||
第一版普通流程优先用显式代码执行。页面不匹配时可以调用有预算的只读诊断;发现新的操作方法后,在维护环境验证并发布新版本。已发布任务不直接把临时探索结果当作新能力包。
|
第一版普通流程优先用显式代码执行。页面不匹配时可以调用有预算的只读诊断;发现新的操作方法后,在维护环境验证并发布新版本。已发布任务不直接把临时探索结果当作新能力包。
|
||||||
|
|
||||||
### 5.5 参考项目与本项目的边界
|
### 5.7 参考项目与本项目的边界
|
||||||
|
|
||||||
选型以当前业务需要和执行方式为准。browser-use 的 Agent 可以探索页面,也提供直接动作与无 LLM 的初始动作;不能把它等同于所有步骤都必须调用大模型。参见 [官方 Agent 配置](https://docs.browser-use.com/open-source/customize/agent/all-parameters)。这些能力可供参考,不构成必须采用它的理由。
|
选型以当前业务需要和执行方式为准。browser-use 的 Agent 可以探索页面,也提供直接动作与无 LLM 的初始动作;不能把它等同于所有步骤都必须调用大模型。参见 [官方 Agent 配置](https://docs.browser-use.com/open-source/customize/agent/all-parameters)。这些能力可供参考,不构成必须采用它的理由。
|
||||||
|
|
||||||
|
|
@ -167,7 +256,41 @@ A2A 与 browser_use 提供设计和实现经验,本项目独立定义需要的
|
||||||
|
|
||||||
此前对本机代码的只读核对显示,A2A 学习工程已有 Playwright 参数化动作、定位和等待实现,旧 browser_use 工程也有固定流程与会话管理实现。这些是可参考的实现案例,不是本项目的依赖或现成获客链路;测试宿主也不能直接视为商家生产执行器。
|
此前对本机代码的只读核对显示,A2A 学习工程已有 Playwright 参数化动作、定位和等待实现,旧 browser_use 工程也有固定流程与会话管理实现。这些是可参考的实现案例,不是本项目的依赖或现成获客链路;测试宿主也不能直接视为商家生产执行器。
|
||||||
|
|
||||||
首轮围绕搜索、主页和评论构建最小只读链路,记录完成情况、读取正确性、模型调用量、总耗时以及 Windows/macOS 分发维护工作量。只有出现明确的探索、视觉或定位问题时,再针对该步骤评估 Midscene 等辅助方案。正常流程不能同时由两个驱动无协调地控制同一浏览器会话。
|
本轮进一步参考 `D:\qiekj\PenguinUniOS-Workspace\projects\learning` 的观察取证、操作知识、候选校验、参数装配、试跑读回及失败修订设计,补齐本项目学习闭环。其全站分层探索、多工作者领取与租约、跨端能力供给及知识检索体系暂不引入;模块职责可以借鉴,具体存储、协议和实现独立决定。参考工程的独立闭环验收仍有未完成项,不能将其设计或局部通过记录视为整套能力已验证。
|
||||||
|
|
||||||
|
首轮围绕搜索、主页和评论构建最小只读链路,记录完成情况、读取正确性、模型调用量、总耗时以及 Windows/macOS 分发维护工作量。第 4.4 节中现有测试工程的桌面与后台候选模块单独提取验证,不将其中的校园编排作为本项目执行前提。只有出现明确的探索、视觉或定位问题时,再针对该步骤评估 Midscene 等辅助方案。正常流程不能同时由两个驱动无协调地控制同一浏览器会话。
|
||||||
|
|
||||||
|
### 5.8 学习后台的实际迁入边界(2026-10-22)
|
||||||
|
|
||||||
|
本轮按当前决定,以 `PenguinUniOS-Workspace/projects/learning` 的学习后台为来源,直接裁剪适用的 React/Ant Design 组件、页面结构和交互流程。原工程保持只读,不复制账号、环境配置、数据库、浏览器资料、依赖目录或构建产物;新工程拥有独立认证、数据目录、端口和进程。
|
||||||
|
|
||||||
|
学习进度、学习批次、候选能力、参数与操作链详情、脚本测试、验证历史和证据回看接本项目接口。原后台的多机监控、租约派发、消费者回执、语义检索、系统账号管理不作为本机学习端的前置;未接入的模块不放置可点击的空壳。
|
||||||
|
|
||||||
|
后端使用 FastAPI + 独立 PostgreSQL,浏览器由 Node.js Playwright 工作进程执行。采用 PostgreSQL 可保留原事务、行锁与队列设计,但原 ConsoleRepository/FrontierRepository 还绑定 A2A 业务合同,部分证据文件与运行器依赖 POSIX/macOS,不能整包导入后宣称兼容 Windows。本轮适配不可变候选、动作证据引用和测试历史机制,保留后续扩展接口。
|
||||||
|
|
||||||
|
本机学习后台的空数据库首次启动时初始化 `admin / 123456`,以 scrypt 摘要保存密码。已有账号时跳过初始化,重启不重置密码;该账号属于本机维护工具,与后续云端商业后台和平台账号分别管理。云端商业数据库仍按 PostgreSQL 规划。
|
||||||
|
|
||||||
|
第一版可人工选择页面元素,记录参数化填写、点击与读取,生成固定候选;校验与试跑各自追加记录。实际读取结果交由后台固定规则对照预期,此项不等同于原项目的完整独立身份读回、失败恢复和发布供给体系。尚未接入模型自动理解与规划。
|
||||||
|
|
||||||
|
用本机演示页面先验证「搜索 → 详情 → 评论」、不同关键词试跑、错误预期失败、证据留存和重启持久化。Windows 记录不能代替 macOS 实机记录,发布预检保留双端要求;本轮不提供正式能力发布按钮。小红书模板仅预填目标和入口,实际平台登录、定位、只读请求范围与结束条件仍需逐步验证。
|
||||||
|
|
||||||
|
迁入清单与来源见 [后台模块说明](../apps/admin/source-provenance.md)。启动及验证命令见项目 README。
|
||||||
|
|
||||||
|
### 5.9 自主学习核心与检索底座的职责
|
||||||
|
|
||||||
|
本项目首期明确采用本地 Codex 协作学习:维护者给出限定目标并在平台页面自行登录,Codex 通过学习工具观察页面、整理操作知识、编写参数化固定实现、提交候选,并根据程序校验与实际试跑修订。后台保存任务、证据、版本和验证真相。先打通这条开发者学习闭环,后台内置模型调用、无人值守学习与调度按后续需要建设,不作为首条平台链路学习的前置。
|
||||||
|
|
||||||
|
能力验证通过后,由商家端按业务参数执行固定实现;内容相关性与采购意图等运行判断仍按产品方案使用可配置模型。本地 Codex 的开发学习过程与商家执行时的模型调用分别计量,不要求商家运行 Codex。
|
||||||
|
|
||||||
|
原学习端的真实模型入口为 `independent_learning_runner.py`,冻结 orchestrator、frontier、author、diagnose、repair 五份 Skill,交由原生模型 CLI 执行受限工具循环;`agent_dispatch.py` 的传输主要服务失败诊断与修订。后台提供持久状态、预算和固定门禁,模型返回成功不等于链路已学会。
|
||||||
|
|
||||||
|
学习链路为:读取目标与队列 → 原子领取限定节点 → 浏览器观察及补证 → 形成字段、分支和操作链知识 → 作者提交候选 → 固定校验反馈 → 新 attempt 修订 → 接收精确版本 → 自动首测及独立读回。全站 frontier 使用 PG 行锁、租约与最浅层优先;本项目先适配单条已选链路,不需要首先开放全站遍历。
|
||||||
|
|
||||||
|
当前后台已迁入适用的管理页面和录制/候选/验证适配器,尚未迁入模型自主学习、完整操作知识合同、自动作者修订循环与独立身份读回。原 runner 的 `sandbox-exec`、证据文件的 `fcntl/getuid/dir_fd` 和仅 macOS 支持的原子提交需要跨平台替换。增加 PostgreSQL 只解决存储与事务基础,不代表这些学习能力已接好。
|
||||||
|
|
||||||
|
`pg_search` 与 `pgvector` 用于学习后的能力知识检索:正向文档 → embedding → 不可变索引代际 → 激活 → BM25/向量召回。它们不负责观察页面或决定下一步点击。原工程检索模块还有真实模型效果、生产资格桥和正式消费边界;本项目本轮只部署与验证检索扩展,未宣称业务索引或自动检索学习已完成。
|
||||||
|
|
||||||
|
部署采用独立 Compose 项目、PG17、pg_search 0.26.1、pgvector 0.8.6,学习库、检索库和测试库分别命名。镜像及扩展版本以实际安装检查为准,配置与随机数据库密码保存在忽略的本机文件,原工程环境与数据库不参与迁移。
|
||||||
|
|
||||||
## 6. AI 判断与调用成本
|
## 6. AI 判断与调用成本
|
||||||
|
|
||||||
|
|
@ -235,7 +358,7 @@ A2A 与 browser_use 提供设计和实现经验,本项目独立定义需要的
|
||||||
|
|
||||||
关闭主窗口与退出程序分别定义,Windows 托盘和 macOS 菜单栏、Dock 行为通过系统适配处理,界面明确显示任务是否仍在运行。退出程序会停止本地执行;本地任务不会在电脑休眠或关机后继续。两端分别验证恢复唤醒后的浏览器与账号核验。断网或云端不可用时停止开启新的分析和依赖分析的后续动作,保留检查点及未决请求,恢复后对账再继续。
|
关闭主窗口与退出程序分别定义,Windows 托盘和 macOS 菜单栏、Dock 行为通过系统适配处理,界面明确显示任务是否仍在运行。退出程序会停止本地执行;本地任务不会在电脑休眠或关机后继续。两端分别验证恢复唤醒后的浏览器与账号核验。断网或云端不可用时停止开启新的分析和依赖分析的后续动作,保留检查点及未决请求,恢复后对账再继续。
|
||||||
|
|
||||||
任务固定使用创建时的能力包版本;运行中的任务不热切换脚本。发布记录至少包含版本、校验信息和兼容范围,升级或回滚后从支持的检查点继续;不兼容时明确暂停。
|
任务固定使用创建时的能力包版本;运行中的任务不热切换脚本。发布记录至少包含版本、校验信息和兼容范围。客户端升级或回滚后,先确认其支持任务原有的能力包版本,再从支持的检查点继续;不兼容时明确暂停。需要采用不同能力版本时,按第 5.5 节建立关联的新任务并复用可兼容进度,保留跨任务动作去重。
|
||||||
|
|
||||||
## 9. 商业模式与额度
|
## 9. 商业模式与额度
|
||||||
|
|
||||||
|
|
@ -253,11 +376,15 @@ A2A 与 browser_use 提供设计和实现经验,本项目独立定义需要的
|
||||||
|
|
||||||
### 阶段一:验证一个平台的基础能力
|
### 阶段一:验证一个平台的基础能力
|
||||||
|
|
||||||
围绕搜索、视频、主页和评论定义最小能力契约,采用一条明确的执行路径完成验证。需要时参考 A2A 与 browser_use 的实现经验,不设置先盘点或复用旧工程的前置步骤。
|
围绕搜索、视频、主页和评论定义最小能力契约,采用一条明确的执行路径完成验证。候选基础模块的提取验证与平台能力验证可以并行,不以完整旧工程迁移完成为前提。
|
||||||
|
|
||||||
以抖音建材作为候选,在 Windows 与 macOS 上分别验证浏览器登录、搜索、视频、主页、评论和回复是否可以正确处理;明确缺失或受限的功能。先做单账号,不先做完整会员后台。双端使用同一验收清单。
|
从这一阶段就保留观察证据、字段与分支说明、未覆盖项、候选版本和验证用例,执行固定校验与变参试跑。先用简单文件及开发工具承载,避免前期只留下演示脚本,到后期才补无法追溯的学习依据。
|
||||||
|
|
||||||
阶段产物是可重复执行的链路、真实样本和失败记录。若关键网页版功能不足,先调整执行方式或首发平台,再投入完整产品建设。
|
工程基础从第 4.4 节的候选模块提取验证开始:先完成独立桌面启动、后台布局与产品登录的最小链路,并核对新应用数据、端口和进程归属。云端可以采用原 FastAPI 骨架;本地存储与执行按新契约接入。提取不以启动原校园服务或迁移其运行数据为前提。
|
||||||
|
|
||||||
|
第一条学习链路采用小红书搜索笔记、详情和评论读取,在 Windows 与 macOS 上分别验证浏览器登录、页面定位、字段与结束条件;再验证抖音等平台。先做单账号,不先做完整会员后台。双端使用同一验收清单。
|
||||||
|
|
||||||
|
阶段产物是可重复执行的链路、结构化学习记录、独立验证记录库、真实样本、变参结果和失败记录。验证首次人工登录、同目录重开、软件重启和会话失效后的行为。若关键网页版功能不足,先调整执行方式或首发平台,再投入完整产品建设。
|
||||||
|
|
||||||
### 阶段二:完成发现与筛选
|
### 阶段二:完成发现与筛选
|
||||||
|
|
||||||
|
|
@ -265,7 +392,7 @@ A2A 与 browser_use 提供设计和实现经验,本项目独立定义需要的
|
||||||
|
|
||||||
### 阶段三:完成学习资产与恢复
|
### 阶段三:完成学习资产与恢复
|
||||||
|
|
||||||
把链路整理成统一能力包,建立验证、版本发布和回滚。验证重启、登录失效、页面变化、人工接管、列表变化和预算耗尽后的行为。
|
将前期学习资料与候选整理为统一能力包,完善校验、试跑记录、版本发布和回滚工具。验证重启、登录失效、页面变化、人工接管、列表变化和预算耗尽后的行为,并覆盖失败证据回流、候选修订及回归;明确哪些属于人为构造场景、哪些已有实际运行证据。
|
||||||
|
|
||||||
### 阶段四:接入跟进闭环
|
### 阶段四:接入跟进闭环
|
||||||
|
|
||||||
|
|
@ -277,30 +404,37 @@ A2A 与 browser_use 提供设计和实现经验,本项目独立定义需要的
|
||||||
|
|
||||||
### 阶段六:扩展覆盖
|
### 阶段六:扩展覆盖
|
||||||
|
|
||||||
单平台流程和商业用量稳定后,依次增加推荐流、多账号并行、小红书与快手、可选线索同步和手机查看。每增加一个平台都新增适配与验证,不把它描述为自动通用。
|
单平台流程和商业用量稳定后,依次增加推荐流、多账号并行、抖音与快手、可选线索同步和手机查看。每增加一个平台都新增适配与验证,不把它描述为自动通用。
|
||||||
|
|
||||||
## 11. 首轮验收要回答的问题
|
## 11. 首轮验收要回答的问题
|
||||||
|
|
||||||
|
- 一条能力从页面证据、字段与分支说明到实现和用例是否可追溯,尚未覆盖哪些状态。
|
||||||
|
- 固定校验、变参试跑和版本发布是否分别有记录;更换关键词、对象和列表状态后,输出与实际页面是否仍对应。
|
||||||
|
- 学习端能否按能力版本、参数、用例、系统和浏览器回看独立验证记录,修订与重跑是否保留原结论。
|
||||||
- 每类页面是否真的能完成操作和读取,读出的对象、文本与来源是否对应。
|
- 每类页面是否真的能完成操作和读取,读出的对象、文本与来源是否对应。
|
||||||
- 同行评论和需求作者两条业务路径能否分别形成可回看的线索。
|
- 同行评论和需求作者两条业务路径能否分别形成可回看的线索。
|
||||||
- 内容、角色和意图判断各自有哪些误判、漏判和信息不足的样本。
|
- 内容、角色和意图判断各自有哪些误判、漏判和信息不足的样本。
|
||||||
- 每千条分析、每批任务和每条人工确认线索的模型费用与总耗时是多少。
|
- 每千条分析、每批任务和每条人工确认线索的模型费用与总耗时是多少。
|
||||||
- 登录失效、页面变化、崩溃、额度耗尽和人工接管后,是否能继续或明确停止。
|
- 登录失效、页面变化、崩溃、额度耗尽和人工接管后,是否能继续或明确停止。
|
||||||
|
- 平台登录能否完全由商家在官方页面完成,登录信息是否排除在日志、证据和模型输入之外;同目录重开、软件重启和升级后是否复用并重新核验会话。
|
||||||
|
- 登录后同会话移交、浏览器最小化、手动关闭及重新显示的行为在两端是否符合定义,当前账号身份是否始终与任务对应。
|
||||||
|
- 出现未覆盖分支或链路失效后,能否保留证据、修订候选并完成回归;是否避免在旧任务中直接替换未验证实现。
|
||||||
- 跟进结果能否核对,重复对象和不确定结果会怎样处理。
|
- 跟进结果能否核对,重复对象和不确定结果会怎样处理。
|
||||||
- 客户端、服务端和供应商用量是否能对账,重试是否导致重复扣费。
|
- 客户端、服务端和供应商用量是否能对账,重试是否导致重复扣费。
|
||||||
- 安装到已确认的 Windows 与 macOS 系统版本、架构的干净目标环境后,浏览器、原生依赖、执行进程、升级和数据保留是否正常。
|
- 安装到已确认的 Windows 与 macOS 系统版本、架构的干净目标环境后,浏览器、原生依赖、执行进程、升级和数据保留是否正常。
|
||||||
- 相同功能在 Windows 与 macOS 是否均通过,窗口关闭、完整退出、快捷键和休眠恢复是否符合各端定义。
|
- 相同功能在 Windows 与 macOS 是否均通过,窗口关闭、完整退出、快捷键和休眠恢复是否符合各端定义。
|
||||||
|
- 候选基础模块提取后是否具备独立标识、目录与进程生命周期,是否仍意外依赖原校园数据、旧工程数据库或客户端供应商密钥。
|
||||||
|
|
||||||
这些是建议验收方向。目前没有实测结果,也没有确定准确率、日处理量或交付周期。
|
这些是产品验收方向。本机学习基础、PG 存储和隔离示例已有工程验证,结果与边界见开发进度文档;真实平台准确率、日处理量、双端业务效果和交付周期尚未确定。
|
||||||
|
|
||||||
## 12. 建议的后续工程结构
|
## 12. 建议的后续工程结构
|
||||||
|
|
||||||
以下为后续组织方式,当前尚未创建这些应用和包:
|
当前已建立 `apps/admin/` 学习工作台、`apps/server/` 本机维护 API 和 `tools/learning/` 浏览器工作进程。后续随业务扩展拆分,建议结构如下;未建立的包不代表已有实现:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
apps/desktop/ 商家桌面界面与主进程
|
apps/desktop/ 商家桌面界面与主进程
|
||||||
apps/server/ 产品账号、AI 网关、权益、订单、计量和发布 API
|
apps/server/ 已有本机学习 API;云端产品 API 后续独立部署
|
||||||
apps/admin/ 订单、用量、版本与运营管理
|
apps/admin/ 已有维护者学习后台;商业运营模块后续补充
|
||||||
tools/learning/ 维护者的学习、调试和验证入口
|
tools/learning/ 维护者的学习、调试和验证入口
|
||||||
packages/contracts/ 任务、能力包、线索、错误和模型结果定义
|
packages/contracts/ 任务、能力包、线索、错误和模型结果定义
|
||||||
packages/runner/ 编排、检查点、动作记录和预算
|
packages/runner/ 编排、检查点、动作记录和预算
|
||||||
|
|
@ -312,7 +446,7 @@ capabilities/ 已验证能力包的清单与版本资产
|
||||||
docs/ 方案与运行说明
|
docs/ 方案与运行说明
|
||||||
```
|
```
|
||||||
|
|
||||||
初期可以保留较少实际包,先明确边界再按代码规模拆分。首轮只需要基础浏览器验证、执行器和最小桌面界面;后台在商业闭环阶段完善。
|
初期保留较少实际包,先明确边界再按代码规模拆分。当前先完成维护者学习后台及执行器,再接商家桌面端;商业后台在商业闭环阶段完善。
|
||||||
|
|
||||||
## 13. 本轮建议与仍需验证的选择
|
## 13. 本轮建议与仍需验证的选择
|
||||||
|
|
||||||
|
|
@ -321,17 +455,54 @@ docs/ 方案与运行说明
|
||||||
- 首版同时适配 Windows 和 macOS,桌面框架采用 Electron。
|
- 首版同时适配 Windows 和 macOS,桌面框架采用 Electron。
|
||||||
- 保留搜索与推荐流入口,以及同行评论、作者本人有需求两条业务路径。
|
- 保留搜索与推荐流入口,以及同行评论、作者本人有需求两条业务路径。
|
||||||
- 沿用先学习、固化链路、再重复执行的思路,控制运行时的模型调用。
|
- 沿用先学习、固化链路、再重复执行的思路,控制运行时的模型调用。
|
||||||
|
- 学习端独立保留验证记录,关联能力版本、用例、参数、环境、实际结果和证据;商家在平台官方页面自行完成登录。
|
||||||
- 模型由我们在运营后台按用途配置,不绑定某个厂商版本,也不要求默认使用最强模型。
|
- 模型由我们在运营后台按用途配置,不绑定某个厂商版本,也不要求默认使用最强模型。
|
||||||
- A2A 与 browser_use 仅作为参考,不设优先复用要求,实现选择以当前产品需求为准。
|
- 学习后台以 A2A learning 的适用模块为基础裁剪适配,详见第 5.8 节;其他 A2A 与 browser-use Agent 资产按需参考,`browser_use` 桌面壳按第 4.4 节评估提取。
|
||||||
|
|
||||||
### 13.2 当前实施建议
|
### 13.2 当前实施建议
|
||||||
|
|
||||||
以 Playwright 固定执行为主线,围绕当前产品设计轻量执行器;Midscene 保持可选,按实际步骤需求比较。维护者负责能力包学习、验证和发布,云端负责模型网关与用量计量。A2A 与 browser_use 的设计和实现可按需参考。
|
以 Playwright 固定执行为主线,围绕当前产品设计轻量执行器;Midscene 保持可选,按实际步骤需求比较。维护者负责能力包学习、验证和发布,云端负责模型网关与用量计量。A2A 与 browser_use 的设计和实现可按需参考。
|
||||||
|
|
||||||
React + TypeScript、SQLite、Node.js/TypeScript 执行器与服务端、PostgreSQL 是推荐组合。具体组件依据功能适配、运行表现和双平台分发维护成本决定,不预设旧代码保留或迁移目标。
|
学习端采用有界闭环:限定目标与预算,观察取证,整理字段、状态、分支和缺口,生成候选及用例,固定校验,变参试跑与结果核对,发布不可变版本;失败通过维护端修订和回归。先服务六类获客操作,不建设完整通用学习平台。
|
||||||
|
|
||||||
|
React + TypeScript、SQLite、Node.js/TypeScript 执行器与 PostgreSQL 是推荐组合;云端优先验证可采用的 FastAPI 认证与管理骨架,再新增本项目模块。桌面壳与后台可从已检查的测试工程中提取适用基础,具体组件依据功能适配、运行表现和双平台分发维护成本决定。
|
||||||
|
|
||||||
### 13.3 仍需验证的选择
|
### 13.3 仍需验证的选择
|
||||||
|
|
||||||
首发内容平台、具体 model ID 与预算、完整跟进范围、套餐、并行规模、Midscene 是否引入,以及是否采用其他外部组件,仍由实验结果决定。Windows/macOS 最低系统版本和架构覆盖在依赖与打包验证后明确。实现应优先回答当前业务能否闭环,再扩展通用能力。
|
首发内容平台、具体 model ID 与预算、完整跟进范围、套餐、并行规模、Midscene 是否引入,以及是否采用其他外部组件,仍由实验结果决定。Windows/macOS 最低系统版本和架构覆盖在依赖与打包验证后明确。实现应优先回答当前业务能否闭环,再扩展通用能力。
|
||||||
|
|
||||||
外部项目能力依据官方资料核对于 2026 年 10 月 21 日。本文中的整体组合和模块边界是本项目的设计建议,不是外部项目已经提供的整套获客产品。
|
外部框架选型资料核对于 2026 年 10 月 21 日,浏览器持久化及登录状态文档于 10 月 22 日补充核对。本文中的整体组合和模块边界是本项目的设计建议,不是外部项目已经提供的整套获客产品。
|
||||||
|
|
||||||
|
## 14. 当前优化与近期实施规划
|
||||||
|
|
||||||
|
本节维护当前实施顺序;实际完成情况、具体测试及遗留问题以开发进度文档为准。以下事项尚未因写入规划而完成,不承诺固定日期。
|
||||||
|
|
||||||
|
### 14.1 首要:打通本地 Codex 的小红书学习闭环
|
||||||
|
|
||||||
|
首期由维护者与当前本地 Codex 协作,后台内置自主学习 Agent 后置。限定目标为「搜索笔记 → 详情 → 评论读取」,先完成单账号、有限样本和明确结束条件。
|
||||||
|
|
||||||
|
1. **建立可确认的实际会话。** 在学习后台创建小红书批次,使用固定本地账号引用;由维护者在后台打开的专用浏览器中登录并确认当前账号。日常 Chrome 或 Codex 右侧窗口的 Cookie 不作为该会话的登录证明。
|
||||||
|
2. **整理 Codex 可调用的工具与上下文。** 复用现有 API/浏览器工作进程,提供目标、参数、观察、动作、证据、候选与验证反馈。按实际需要补轻量操作入口,不把另建通用 Agent 平台或必须使用 MCP 作为前置。
|
||||||
|
3. **依据真实页面完善站点适配。** Codex 观察搜索、详情、弹窗、评论展开与分页,核对定位和请求事实。当前通用只读请求限制与定位规则是否适合真实小红书需实测;失败时保存依据并修订明确的站点规则。
|
||||||
|
4. **固化最小操作知识与版本。** 记录输入输出、字段来源、定位、等待、分支、结束条件、未覆盖项与证据;生成参数化固定实现和候选,不把一次手动点击或模型声明视为学会。
|
||||||
|
5. **完成变参回归。** 更换关键词和笔记样本,核对输出确实对应当前笔记及评论;验证正常路径、无结果和中断状态,保存成功与失败记录。未覆盖分支明确列出。
|
||||||
|
|
||||||
|
本阶段验收:同一候选能够在维护者实际登录的小红书会话中重放限定路径,改变参数后读取对象与输出仍正确,观察、候选摘要和验证记录可追溯。当前本机演示页的通过记录不能代替此项。
|
||||||
|
|
||||||
|
### 14.2 随后:补齐可交付的能力门禁
|
||||||
|
|
||||||
|
- 根据实际读取目标定义独立来源读回,逐步补当前 `not_configured` 的核验缺口;程序对执行输出的固定断言继续单独记录。
|
||||||
|
- 在 Windows 与 macOS 目标环境分别验证相同能力范围、资料复用、关闭与重开行为;原工程仅 macOS 的 runner/原子落盘机制按需替换,不作为双端已验证代码直接引入。
|
||||||
|
- 完成能力清单、版本兼容与发布/回退接口,并将真实验证记录关联发布条件。当前只有预检,不提前宣称商家可用分发已完成。
|
||||||
|
|
||||||
|
### 14.3 知识增多后:接入能力检索
|
||||||
|
|
||||||
|
- 从操作知识和合格候选/发布状态生成正向检索文档,将限制与 Gap 保留为结构化条件。
|
||||||
|
- 接入真实 embedding 配置、模型版本与维度,建立可追溯索引代际;复用已准备的中文 BM25 与向量查询基础。
|
||||||
|
- 将平台、适用范围及版本/质量过滤接入召回,验证真实样本效果。扩展烟测与固定样本向量排序不等于业务召回质量完成。
|
||||||
|
|
||||||
|
### 14.4 后续产品化与持续优化
|
||||||
|
|
||||||
|
按第 10 节推进 Electron 商家端、固定能力消费、线索与内容判断、云端模型网关及权益/计量。模型用途、预算与链路执行分别管理,首期本地 Codex 学习不要求商家安装 Codex。
|
||||||
|
|
||||||
|
每轮新增优化先更新本节或其所属章节,说明解决的问题、实施顺序和验收依据;相应进度记录引用当前规划。产品目标变化时修订现有条目,避免叠加多个互相冲突的计划文件。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2314695e'/%3E%3Cpath d='M8 24 16 8l8 16M11 18h10' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E">
|
||||||
|
<title>AI 获客助手 方案图解</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f5f6f7;--surface:#fff;--soft:#f0f2f3;--text:#1d252b;--muted:#5c6973;--line:#d9dfe3;--accent:#14695e;--tint:#e5f2ee;--feedback:#94601c;--feedback-bg:#faf0df;--arrow:#7f8b95;--focus:#14695e;--node:#fff;--group:#f5f7f8;--group-line:#dce2e6;--active:#14695e}
|
||||||
|
:root[data-theme="dark"]{--bg:#151a1f;--surface:#1d242b;--soft:#252e37;--text:#edf0f2;--muted:#a9b6c0;--line:#394550;--accent:#83cbbb;--tint:#253e39;--feedback:#e0b46e;--feedback-bg:#403421;--arrow:#98a7b2;--focus:#83cbbb;--node:#202931;--group:#1a2229;--group-line:#3a4650;--active:#83cbbb}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button{color:inherit;cursor:pointer}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--focus);outline-offset:3px}button:disabled{opacity:.4;cursor:default}
|
||||||
|
.shell{max-width:1600px;margin:auto;padding:30px 32px 22px}.mast{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.eyebrow{color:var(--muted);font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase}.mast h1{font-size:27px;line-height:1.4;font-weight:650;letter-spacing:.02em;margin:8px 0}.intro{max-width:820px;color:var(--muted);margin:0;font-size:14px}.mast-actions{display:flex;gap:9px;align-items:center;flex-shrink:0;padding-top:4px}.pill{font-size:12px;padding:4px 10px;border:1px solid var(--line);border-radius:5px;white-space:nowrap}.btn{display:inline-flex;justify-content:center;align-items:center;gap:6px;min-height:35px;border:1px solid var(--line);border-radius:6px;background:var(--surface);padding:5px 11px;white-space:nowrap;font-size:12px}.btn:hover{border-color:var(--accent);background:var(--tint);text-decoration:none}.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--surface)}[data-theme="dark"] .btn.primary{color:#15241f}.facts{display:flex;gap:22px;flex-wrap:wrap;font-size:12px;color:var(--muted);padding:18px 0}.facts b{color:var(--text);font-weight:600;margin-right:6px}.tabs{display:flex;gap:3px;border-bottom:1px solid var(--line);overflow-x:auto;scrollbar-width:thin}.tab{flex-shrink:0;display:flex;gap:9px;align-items:center;background:none;border:0;border-bottom:3px solid transparent;padding:13px 19px 12px;color:var(--muted);font-size:14px}.tab .index{font-size:10px;font-variant-numeric:tabular-nums;opacity:.8}.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}.tab:hover{background:var(--soft);color:var(--text)}
|
||||||
|
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 292px;gap:20px;margin-top:20px;align-items:start}.diagram-panel{min-width:0;background:var(--surface);border:1px solid var(--line);border-radius:9px;overflow:hidden}.panel-head{padding:17px 20px 12px;display:flex;justify-content:space-between;align-items:start;gap:20px}.panel-head h2{font-size:17px;line-height:1.4;margin:0 0 5px;font-weight:650}.panel-head p{margin:0;color:var(--muted);font-size:12px;max-width:780px}.count{font-size:11px;color:var(--muted);white-space:nowrap;padding-top:2px}.toolbar{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:9px;padding:10px 16px;border-block:1px solid var(--line);background:var(--soft)}.tool-group{display:flex;gap:6px;align-items:center}.tool-group .btn{min-height:30px;padding:3px 9px}.zoom{display:inline-flex;justify-content:center;min-width:39px;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}.focus-toggle{font-size:12px;color:var(--muted);display:flex;gap:5px;align-items:center;white-space:nowrap}.focus-toggle input{accent-color:var(--accent)}.viewport{overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}.viewport svg{display:block;width:100%;min-width:820px;height:auto}.legend{display:flex;gap:22px;align-items:center;flex-wrap:wrap;padding:10px 19px 12px;border-top:1px solid var(--line);color:var(--muted);font-size:11px}.legend-item{display:flex;align-items:center;gap:7px}.sample{width:26px;border-top:1.5px solid var(--arrow)}.sample.optional{border-top-style:dashed}.sample.feedback{border-top-style:dashed;border-color:var(--feedback)}.guide{margin-left:auto}
|
||||||
|
.playback{display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:13px 18px;border-top:1px solid var(--line);font-size:12px}.playback select{max-width:230px;color:var(--text);background:var(--surface);border:1px solid var(--line);border-radius:5px;min-height:32px;padding:4px 6px}.play-label{color:var(--muted);margin-right:2px}.step-label{font-variant-numeric:tabular-nums;color:var(--muted);font-size:11px;flex:1;min-width:125px}.step-label b{color:var(--text);font-weight:500}.notes{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:18px}.note{padding-top:12px;border-top:1px solid var(--line)}.note h3{font-size:12px;margin:0 0 5px}.note p{font-size:12px;color:var(--muted);margin:0;line-height:1.8}
|
||||||
|
.inspector{min-width:0;position:sticky;top:18px}.search-box{position:relative;margin-bottom:15px}.search-box input{width:100%;height:38px;border:1px solid var(--line);border-radius:6px;background:var(--surface);padding:7px 12px;color:var(--text);font-size:12px}.results{position:absolute;z-index:5;top:44px;inset-inline:0;background:var(--surface);border:1px solid var(--line);border-radius:6px;max-height:360px;overflow:auto}.results:empty{display:none}.result{display:block;width:100%;padding:9px 12px;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;font-size:12px}.result:last-child{border-bottom:0}.result:hover{background:var(--tint)}.result small{display:block;color:var(--muted);font-size:10px}.search-empty{padding:12px;color:var(--muted);font-size:12px}.detail{border:1px solid var(--line);border-radius:8px;background:var(--surface);padding:19px}.detail-kicker{font-size:10px;color:var(--muted);letter-spacing:.08em}.detail h3{margin:9px 0 8px;font-size:18px;line-height:1.5}.status-tag{display:inline-block;background:var(--soft);padding:3px 7px;border-radius:4px;font-size:10px;color:var(--muted);margin-bottom:13px}.detail-desc{font-size:12px;line-height:1.85;margin:0 0 15px}.detail dl{margin:0}.detail dt{font-size:10px;font-weight:600;letter-spacing:.06em;color:var(--muted);margin:15px 0 4px}.detail dd{font-size:12px;line-height:1.85;margin:0}.detail ul{padding-left:17px;margin:4px 0 0}.detail li{font-size:12px;line-height:1.8;margin:5px 0}.detail-source{border-top:1px solid var(--line);margin-top:17px;padding-top:12px;font-size:11px;color:var(--muted)}.side-foot{color:var(--muted);font-size:11px;margin:12px 3px;line-height:1.8}.footer{display:flex;gap:20px;justify-content:space-between;color:var(--muted);font-size:11px;padding-top:22px;margin-top:20px;border-top:1px solid var(--line)}.notice{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--text);color:var(--surface);padding:10px 18px;border-radius:7px;z-index:20;font-size:12px;max-width:90vw;pointer-events:none}.notice:empty{display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||||
|
svg text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}svg .paper{fill:var(--surface)}svg .svg-heading{fill:var(--text);font-size:18px;font-weight:600}svg .svg-sub{fill:var(--muted);font-size:11px}svg .group-rect{fill:var(--group);stroke:var(--group-line);stroke-width:1}svg .group-title{fill:var(--muted);font-size:12px;font-weight:600}svg .node{cursor:pointer;outline:none}svg .node-box{fill:var(--node);stroke:var(--line);stroke-width:1.2}svg .node-title{fill:var(--text);font-size:15px;font-weight:550}svg .node-sub{fill:var(--muted);font-size:11px}svg .node-badge{fill:var(--muted);font-size:9px}svg .node:hover .node-box,svg .node:focus-visible .node-box{stroke:var(--accent);stroke-width:2}svg .node.selected .node-box{stroke:var(--accent);stroke-width:2;fill:var(--tint)}svg .node.route .node-box{stroke:var(--accent);stroke-width:1.8}svg .node.current .node-box{fill:var(--tint);stroke:var(--accent);stroke-width:2.3}svg .node.dim{opacity:.25}svg .edge{fill:none;stroke:var(--arrow);stroke-width:1.35;stroke-linejoin:round;stroke-linecap:round}svg .edge.optional{stroke-dasharray:6 5}svg .edge.feedback{stroke:var(--feedback);stroke-dasharray:5 4}svg .edge.highlight{stroke:var(--accent);stroke-width:2.2}svg .edge.dim{opacity:.2}svg .edge-label rect{fill:var(--surface)}svg .edge-label text{fill:var(--muted);font-size:10px}svg .edge-label.feedback text{fill:var(--feedback)}svg .edge-label.dim{opacity:.2}svg .arrow-tip{fill:var(--arrow)}svg .arrow-tip.active{fill:var(--accent)}svg .arrow-tip.feedback{fill:var(--feedback)}
|
||||||
|
@media(min-width:1700px){.shell{padding-top:40px}.workspace{grid-template-columns:minmax(0,1fr) 310px;gap:24px}}
|
||||||
|
@media(max-width:1120px){.workspace{grid-template-columns:minmax(0,1fr)}.inspector{position:static;display:grid;grid-template-columns:250px 1fr;gap:18px}.detail{grid-column:2;grid-row:1 / span 2}.search-box{margin:0}.side-foot{grid-column:1;align-self:start}.mast h1{font-size:24px}.guide{margin-left:0}}
|
||||||
|
@media(max-width:680px){.shell{padding:20px 14px}.mast{display:block}.mast-actions{padding-top:15px}.intro{font-size:12px}.facts{gap:8px 18px;padding:14px 0}.tab{padding:12px 13px;font-size:12px}.workspace{margin-top:14px;gap:14px}.panel-head{padding:15px}.panel-head h2{font-size:16px}.count{display:none}.toolbar{padding:8px 10px}.tool-group{gap:4px}.tool-group .btn{padding:3px 8px}.focus-toggle{font-size:11px}.playback{padding:11px}.playback select{max-width:185px}.step-label{flex-basis:100%;margin:1px 0 0}.legend{gap:10px 16px;padding:10px 12px}.guide{display:none}.notes{grid-template-columns:1fr;gap:12px}.inspector{display:block}.search-box{margin-bottom:12px}.detail{padding:16px}.footer{display:block}.footer span{display:block;margin:4px 0}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
||||||
|
@media print{body{background:white;color:black}.shell{padding:0}.mast-actions,.tabs,.toolbar,.playback,.inspector,.notes,.footer,.legend,.facts{display:none}.workspace{display:block;margin:10px 0}.diagram-panel{border:0}.viewport{overflow:visible}.viewport svg{width:100%!important;min-width:0!important}.panel-head{padding:0 0 10px}.notice{display:none}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="mast">
|
||||||
|
<div><div class="eyebrow">ASSISTANT / PRODUCT & ENGINEERING</div><h1>AI 获客助手 方案图解</h1><p class="intro">从商家配置商品,到发现需求、形成线索与跟进;将平台操作学习为经过验证的能力,再由桌面端重复执行。</p></div>
|
||||||
|
<div class="mast-actions"><span class="pill">v0.2 · 设计阶段</span><button class="btn" id="theme" type="button">切换深色</button><a class="btn" href="整体方案-v0.2.md">文字方案 ↗</a></div>
|
||||||
|
</header>
|
||||||
|
<div class="facts"><span><b>桌面</b>Windows + macOS</span><span><b>执行</b>Electron + Playwright</span><span><b>模型</b>运营后台按用途配置</span><span><b>更新</b>2026.10.22</span></div>
|
||||||
|
<nav class="tabs" id="tabs" role="tablist" aria-label="方案图页"></nav>
|
||||||
|
<div class="workspace">
|
||||||
|
<section>
|
||||||
|
<div class="diagram-panel" id="diagram-panel">
|
||||||
|
<div class="panel-head"><div><h2 id="diagram-title"></h2><p id="diagram-description"></p></div><span class="count" id="diagram-count"></span></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="tool-group"><button class="btn" id="zoom-out" aria-label="缩小" type="button">−</button><span class="zoom" id="zoom-value">100%</span><button class="btn" id="zoom-in" aria-label="放大" type="button">+</button><button class="btn" id="fit" type="button">适应宽度</button><label class="focus-toggle"><input id="focus" type="checkbox">聚焦关联</label></div>
|
||||||
|
<div class="tool-group"><button class="btn" id="export-svg" type="button">导出 SVG</button><button class="btn" id="export-png" type="button">导出 PNG</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="viewport" id="viewport" role="region" aria-label="可滚动方案图" tabindex="0"></div>
|
||||||
|
<div class="legend"><span class="legend-item"><span class="sample"></span>固定职责或流程</span><span class="legend-item"><span class="sample optional"></span>可选或后续</span><span class="legend-item"><span class="sample feedback"></span>异常或修订</span><span class="guide">点击节点查看细节 · 窄屏可横向滚动</span></div>
|
||||||
|
<div class="playback"><span class="play-label">路径演示</span><select id="scenario" aria-label="选择演示路径"></select><button class="btn" id="prev" aria-label="上一步" type="button">上一步</button><button class="btn primary" id="play" type="button">播放</button><button class="btn" id="next" aria-label="下一步" type="button">下一步</button><span class="step-label" id="step-label"></span><button class="btn" id="reset" type="button">清除</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="notes"><section class="note"><h3 id="note-title-a"></h3><p id="note-a"></p></section><section class="note"><h3 id="note-title-b"></h3><p id="note-b"></p></section></div>
|
||||||
|
</section>
|
||||||
|
<aside class="inspector" aria-label="节点详情">
|
||||||
|
<div class="search-box"><label class="sr-only" for="search">搜索所有图页的节点</label><input type="search" id="search" placeholder="搜索节点,例如 变参 / 额度 / 恢复" autocomplete="off" aria-controls="results" aria-expanded="false"><div class="results" id="results"></div></div>
|
||||||
|
<div class="detail" id="detail" aria-live="polite"></div>
|
||||||
|
<p class="side-foot">图中展示整体设计;学习后台基建已启动,具体实现以 README 为准。真实平台与双端验收待完成。路径播放仅演示设计关系。</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<footer class="footer"><span>来源:AI 获客助手整体方案 v0.2 · 2026 年 10 月 22 日</span><span>独立离线文件 · 无外部资源依赖</span></footer>
|
||||||
|
</main>
|
||||||
|
<div class="notice" id="notice" role="status"></div>
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
// 同步来源 docs/整体方案-v0.2.md。图页数据与渲染器均内联,文件可离线打开。
|
||||||
|
const N=(id,x,y,w,title,sub,desc,input,output,checks,source,status="proposed",h=74)=>({id,x,y,w,h,title,sub,desc,input,output,checks,source,status});
|
||||||
|
const E=(a,b,label="",opt={})=>({a,b,label,...opt});
|
||||||
|
const G=(x,y,w,h,title)=>({x,y,w,h,title});
|
||||||
|
const diagrams=[
|
||||||
|
{id:"architecture",name:"系统架构",title:"三个环境 一套能力契约",description:"维护端生产能力,商家电脑执行任务,云端管理模型、权益与计量。模块表示职责,可先合并部署。",w:1220,h:842,
|
||||||
|
groups:[G(25,100,290,640,"维护者环境"),G(345,100,485,640,"商家电脑 · Windows / macOS"),G(860,100,335,640,"云端服务")],
|
||||||
|
nodes:[
|
||||||
|
N("observe",50,150,240,"学习与观察","目标范围 · 页面证据","我们在维护环境观察和调试平台链路,记录操作前后状态与未知分支。","平台入口、能力目标、步骤与模型预算","页面证据、操作知识、未覆盖项",["维护工具先服务少量获客能力","学习成本与商家运行用量分开"],"5.1–5.4"),
|
||||||
|
N("candidate",50,265,240,"候选能力","参数实现 + 契约 + 用例","手写或由模型辅助产生候选,统一接受固定校验与浏览器试跑。","操作知识与证据","候选实现、输入输出契约、用例",["文字说明或录制结果不能直接发布","学习资料和商家执行包分开"],"5.2–5.3"),
|
||||||
|
N("verify",50,380,240,"校验与双端试跑","结构校验 · 变参 · 结果核对","先检查产物结构和规则,再验证不同参数、页面状态以及两种操作系统。","候选版本、样本、目标环境","校验记录、试跑结果、缺口",["校验通过与实际执行成功分别记录","Windows 与 macOS 分别通过"],"5.2、11"),
|
||||||
|
N("learning-db",50,495,240,"学习验证记录库","独立 PostgreSQL + 证据文件","维护者学习端单独记录校验、试跑、结果核对及双端验收,保留每次重跑和修订的历史。","能力版本与校验值、用例、参数、环境与实际结果","可按能力、版本、用例和系统回看的验证记录",["与商家业务数据库隔离","发布清单关联具体通过记录"],"5.3","confirmed"),
|
||||||
|
N("package",50,610,240,"不可变能力版本","版本号 · 校验值 · 兼容范围","发布满足覆盖范围和验收要求的能力版本,保留旧版本及验证依据。","已验证候选与兼容信息","可发布的能力包与清单",["修改产生新版本","任务绑定创建时的能力版本"],"5.3、8"),
|
||||||
|
N("desktop",375,150,425,"Electron 桌面界面","账号 · 商品 · 任务 · 线索 · 用量","商家配置业务条件、查看执行结果和接管浏览器。可从测试工程提取窗口、IPC、产品登录和状态展示基础,再适配获客页面。","商家商品条件、任务配置","受限 IPC 任务请求与结果展示",["Windows 与 macOS 首版同时适配,原打包只验证过 Windows","浏览器操作在独立执行进程中运行"],"2、4.1、4.4","confirmed"),
|
||||||
|
N("runner",375,265,425,"任务与链路执行器","Node.js / TypeScript 建议","编排已发布能力、检查点、动作账目和预算,按用途请求 AI 分析。可借用原进程生命周期接口,接入本项目执行进程。","任务快照、能力版本、账号会话","执行进度、线索、待核对动作",["首版账号内串行执行","原 Python、校园编排和本地 PostgreSQL 不随桌面壳整套引入"],"4、8"),
|
||||||
|
N("adapter",375,380,195,"平台适配器","平台链路与契约","将通用任务要求映射到某个平台的能力输入输出。","固定版本的能力及参数","平台操作与读取结果",["每个平台分别学习和验证","不把框架对象存为业务契约"],"4.2、5.7"),
|
||||||
|
N("sqlite",610,380,190,"商家业务 SQLite","任务 · 线索 · 账号目录映射","保存商品、任务、线索、检查点与动作核对结果,以及浏览器目录引用和最后核验时间。","执行进展、分析结果、来源","可恢复的业务记录",["与维护者学习验证库隔离","平台登录资料由本地浏览器管理"],"4.3、7–8"),
|
||||||
|
N("playwright",375,495,195,"Playwright","固定浏览器执行","用显式代码完成定位、等待、点击、输入、读取和结果核对。","当前页面、能力参数","页面数据与动作证据",["每个会话只有一个驱动持有控制权","固定代码本身不产生 LLM Token"],"4.2、6.3"),
|
||||||
|
N("midscene",610,495,190,"Midscene 可选","视觉与局部诊断","遇到明确的视觉或定位需求时再评估引入,相关模型调用接入 AI 网关。","需视觉辅助的局部步骤","定位或诊断结果",["首版不是必选依赖","缓存不等于整条链路零模型费用"],"5.6、6.3","pending"),
|
||||||
|
N("browser",375,610,195,"专用浏览器会话","按账号持久化 · 人工登录","主线使用独立可最大化浏览器;原 WebContentsView 宿主作为待验证候选。按账号复用固定目录或稳定分区,任务先核验身份。","专用账号目录或持久化分区、当前任务","已核验的会话、等待登录或等待处理状态",["原新会话随机分区要改为稳定账号映射","最小化及内嵌收起效果需双端验证;关闭则暂停保存进度"],"4.1–4.4"),
|
||||||
|
N("api",885,150,285,"产品账号与权益 API","产品登录 · 订阅 · 设备","可采用原 FastAPI 产品认证、权限和审计骨架,学校模型改为商家模型;订阅、订单和设备权益是本项目新增模块。","产品登录、设备及订单请求","登录状态、软件有效期与权益",["产品账号与第三方平台账号分别管理","原 deviceId 记录不等同于商业设备权益"],"4.3–4.4、9"),
|
||||||
|
N("gateway",885,265,285,"AI 网关","按用途路由 · 预算 · 请求标识","统一处理分析接口、结果格式、供应商调用与用量记录。","业务用途、必要内容、任务快照","结构化分析结果与实际计量",["供应商密钥保留服务端","相同请求标识复用已完成结果"],"4.3、6.4、9"),
|
||||||
|
N("models",885,380,285,"模型与运营配置","文本判断 · 复核 · 视觉 · 学习","我们在后台按四类用途配置模型与预算,不将厂商型号写死在客户端。","用途配置、真实样本和预算","已验证的模型配置版本",["文字模型与视觉模型分别校验","模型效果和成本按样本验证"],"6.4","confirmed"),
|
||||||
|
N("ledger",885,495,285,"订单与额度账本","预占 · 实际计量 · 结算","记录商家权益与额度,以及供应商实际消耗,分别对账。","请求标识、预占和实际消耗","额度余额、扣费与对账记录",["商家分析额度不等同供应商 Token","超时可能已发生费用,保留未决状态"],"9"),
|
||||||
|
N("release",885,610,285,"版本发布清单","能力包与双端客户端","发布版本、校验与兼容信息,客户端按任务固定版本执行。可借用原配置修订发布机制,替换为本项目能力契约。","经过验证的能力与客户端构建","可用版本及回滚信息",["Windows 打包模板可参考,macOS 分发链和升级回滚仍需建设","新能力版本用于新任务"],"4.1、4.4、5.5、8")
|
||||||
|
],
|
||||||
|
edges:[E("observe","candidate"),E("candidate","verify"),E("verify","learning-db","保存记录"),E("learning-db","package","关联通过记录"),E("desktop","runner"),E("runner","adapter","执行",{from:"bottom",fromAt:97.5/425,to:"top"}),E("runner","sqlite","保存",{from:"bottom",fromAt:.8,to:"top"}),E("adapter","playwright"),E("playwright","browser"),E("runner","midscene","按需调用",{kind:"optional",from:"right",fromAt:.78,to:"top",via:[[822,322.72],[822,470],[705,470]],lx:705,ly:470}),E("midscene","playwright","辅助",{kind:"optional",from:"left",to:"right"}),E("midscene","gateway","视觉调用",{kind:"optional",from:"right",to:"bottom",toAt:.95,via:[[840,532],[840,355],[1155.75,355]],lx:1065,ly:355}),E("desktop","api","权益",{from:"right",to:"left"}),E("runner","gateway","分析",{from:"right",to:"left"}),E("gateway","models"),E("gateway","ledger","计量",{from:"right",to:"right",via:[[1183,302],[1183,532]],lx:1150,ly:466}),E("api","ledger","账本",{from:"right",to:"right",via:[[1190,187],[1190,532]],lx:1150,ly:238}),E("package","release","版本化发布",{from:"bottom",to:"bottom",via:[[170,765],[1027,765]],lx:560,ly:765}),E("release","runner","拉取固定版本",{from:"left",to:"right",via:[[845,647],[845,302]],lx:842,ly:596}),E("sqlite","observe","诊断导出 → 维护端导入",{from:"right",to:"left",kind:"feedback",via:[[815,417],[815,720],[12,720],[12,187]],lx:290,ly:720})],
|
||||||
|
paths:[{name:"能力从学习到运行",ids:["observe","candidate","verify","learning-db","package","release","runner","adapter","playwright","browser"]},{name:"商家任务与 AI 分析",ids:["desktop","runner","gateway","models"]},{name:"可选视觉步骤接入网关",ids:["runner","midscene","gateway","models"]}],
|
||||||
|
notes:[["采用适用的工程基础","测试工程的 Electron 壳、产品账号认证和管理后台列为候选提取范围。云端可沿用 FastAPI/PostgreSQL 骨架;本地保持 SQLite 和轻量执行进程,具体改造见文字方案 §4.4。"],["仍需独立建设与验证","原校园编排、密码代配和随机会话隔离不直接照搬。获客链路、AI 网关、商业账本与双端分发仍需实现;内嵌宿主与 Midscene 分别按需求验证。"]]
|
||||||
|
},
|
||||||
|
{id:"acquisition",name:"获客流程",title:"相关内容到可回看的客户线索",description:"同行商家视频的评论区与作者本人有采购需求是两条不同路径。相关性、角色和采购意图分别判断。",w:1220,h:862,
|
||||||
|
groups:[G(25,100,1170,710,"商家运行端 · 搜索入口先行,推荐流后续扩展")],
|
||||||
|
nodes:[
|
||||||
|
N("product",55,155,220,"商品与客户条件","商品 · 地区 · 排除条件","商家提供商品、使用场景、服务地区与目标客户条件。","商家配置","版本化商品条件",["未设置的信息不自行补齐","分析结果绑定商品条件版本"],"2、6.1"),
|
||||||
|
N("entry",350,155,220,"搜索或推荐流","首轮先验证关键词搜索","按任务入口发现本轮候选内容,记录来源及进度。","关键词或入口、范围与预算","候选视频和来源",["不承诺枚举无限列表全部内容","推荐流放在后续扩展阶段"],"3、8、10"),
|
||||||
|
N("video",655,155,220,"视频相关性判断","文字优先 · 信息不足再补充","结合商品条件判断内容是否相关,必要时读取更多上下文。","标题、文本及必要补充内容","相关、不相关或待判断及依据",["相似度不能代替采购意图","无相关内容时继续发现或按预算结束"],"3、6.1–6.2"),
|
||||||
|
N("profile",945,155,220,"主页与作品读取","当前账号与作品来源","进入作者主页,读取简介及必要作品用于判断角色和需求。","相关视频及作者标识","账号信息、作品与来源",["对象身份与昵称分开","不能混用视频作者与评论者"],"3、7"),
|
||||||
|
N("role",945,300,220,"账号角色与需求","角色可多选","区分同行商家、采购方与信息不足,也允许同一账号兼具多种角色。","主页与内容上下文","角色标签与当前需求",["同行身份不排除采购需求","分支按本次商品与需求决定"],"3、6.1"),
|
||||||
|
N("comments",625,300,230,"同行视频评论与回复","保留父子关系与来源","读取与商品有关的视频评论及必要回复,查找需求信号。","视频标识、进度与读取范围","评论、回复、来源及下一状态",["动态列表每次定位当前对象","父评论和必要回复作为判断上下文"],"3、5.3、6.1"),
|
||||||
|
N("intent",315,300,230,"评论采购意图判断","原文依据 · 缺失信息","将需求信号与商家商品条件匹配,区分真实采购、闲聊和信息不足。","评论上下文与商品条件","采购意图与原文依据",["保留评论作者标识","新上下文变化后重新判断"],"6.1–6.2"),
|
||||||
|
N("buyer",945,445,220,"作者需求核对","采购条件与商品是否匹配","作者本人表达需求时,核对商品、场景和地区等必要条件。","作者需求与商家条件","可进入线索的匹配依据",["不能只因账号相关就判定客户","缺少条件时保留待判断"],"3、6.1"),
|
||||||
|
N("pending",625,445,230,"信息不足待判断","保留依据与缺失项","对当前信息无法支持判断的对象保留状态,不自动归为有效客户。","模糊内容或缺失条件","待判断对象与缺口",["模型自评分不作为真实准确率","不因缺少关键词直接排除所有模糊样本"],"3、6.1–6.3"),
|
||||||
|
N("lead",315,590,230,"线索去重与依据保存","对象标识 · 多条需求来源","归并可靠确认的同一对象,保留原始评论和内容来源。","两条业务路径的匹配结果","可回看的本地线索",["相同昵称不能直接认定同一人","内容、客户、动作分别去重"],"7"),
|
||||||
|
N("follow",625,590,230,"关注与私信任务","按阶段验证开放范围","按商品与任务配置准备跟进内容,记录目标与动作业务标识。","线索、跟进设置与模板版本","已准备或已执行的动作账目",["自动跟进保留为产品目标","实际首版范围依据功能验证结果确定"],"2、7、10","pending"),
|
||||||
|
N("readback",945,590,220,"结果核对与后续回复","确认 / 失败 / 结果未知","读取实际效果,记录关注、发送与回复状态。","动作标识与可核对页面证据","已确认结果或待核对记录",["未知结果先核对,不能直接重发","去重跨任务和模板变更生效"],"7、8")
|
||||||
|
],
|
||||||
|
edges:[E("product","entry"),E("entry","video"),E("video","profile","相关 / 补充"),E("profile","role"),E("role","comments","同行",{from:"left",to:"right"}),E("comments","intent", "评论需求",{from:"left",to:"right"}),E("role","buyer","作者有需求"),E("role","pending","信息不足",{from:"bottom",to:"right",via:[[1055,410],[880,410],[880,482]],lx:886,ly:410}),E("intent","lead","匹配",{from:"bottom",to:"top"}),E("buyer","lead","匹配",{from:"bottom",to:"top",via:[[1055,555],[430,555]],lx:760,ly:555}),E("lead","follow"),E("follow","readback"),E("video","entry","不相关则继续发现",{from:"top",to:"top",via:[[765,125],[460,125]],lx:600,ly:125}),E("intent","pending","需更多信息",{from:"bottom",to:"left",via:[[430,482]],lx:535,ly:482})],
|
||||||
|
paths:[{name:"从同行评论发现采购方",ids:["product","entry","video","profile","role","comments","intent","lead","follow","readback"]},{name:"作者本人表达采购需求",ids:["product","entry","video","profile","role","buyer","lead","follow","readback"]},{name:"信息不足保留待判断",ids:["product","entry","video","profile","role","pending"]}],
|
||||||
|
notes:[["商家配置的是业务条件","关键词帮助发现候选内容;商品与目标客户条件决定匹配结果。平台动作能够复用,同一对象对不同商家的匹配结论可能不同。"],["首条学习链路已选定","小红书搜索笔记 → 详情 → 评论读取。先验证本机学习工具链,再适配真实页面,随后验证线索判断与后续跟进。"]]
|
||||||
|
},
|
||||||
|
{id:"learning",name:"学习发布",title:"有界学习与能力发布闭环",description:"观察证据、操作知识、候选实现和执行验证分别留存。结构校验通过、真实试跑通过、已发布是不同状态。",w:1220,h:862,
|
||||||
|
groups:[G(25,100,1170,710,"维护者环境 · 六类操作逐步学习,商家端执行固定版本")],
|
||||||
|
nodes:[
|
||||||
|
N("goal",55,160,225,"限定目标与预算","能力 · 入口 · 角色 · 分支","学习开始先限定本次能力、覆盖范围和消耗边界。","平台能力目标与已知入口","学习范围、预算、待覆盖分支",["每条评论不是一条新学习任务","第一版不建设全站探索和多机调度"],"5.1、5.4"),
|
||||||
|
N("observe",350,160,225,"观察与证据","操作前后状态 · 对象 · 来源","记录操作、当前对象、定位候选、匹配数量、等待和页面依据。","真实页面与操作","观察记录、截图或快照引用",["证据有时间和所属对象","保存范围受学习预算限制"],"5.2"),
|
||||||
|
N("knowledge",645,160,225,"字段 状态 分支","值来源 · 前后条件 · 效果","将观察整理为可解释的操作知识,分清已理解与未覆盖情况。","观察证据","结构化操作知识及缺口",["字段类型、必填和取值来源明确","有结果、无结果、登录失效分别描述"],"5.1–5.2"),
|
||||||
|
N("gaps",940,160,225,"未覆盖项","已观察与已验证分别记录","未见过的分支和证据不足单独登记,可缩小能力发布范围或追加观察。","未知条件和证据缺口","覆盖说明与后续学习目标",["不能将见过页面记作执行成功","缺口不因候选生成而自动消失"],"5.1–5.2"),
|
||||||
|
N("candidate",645,330,225,"候选能力与用例","TypeScript + JSON 可起步","从操作知识形成参数化实现、输入输出契约与用例,绑定证据。","操作知识、参数定义与证据","候选版本与验证用例",["手写和模型生成使用同一验收要求","Midscene 缓存不直接等于能力包"],"5.2–5.3"),
|
||||||
|
N("check",350,330,225,"固定规则校验","结构 · 类型 · 绑定 · 覆盖","程序检查契约、动作、等待、输出映射和证据引用,拒绝不合格候选。","候选能力、清单与用例","通过或拒绝及原因",["校验不是由模型自行宣布通过","固定校验不能代替真实浏览器验证"],"5.2"),
|
||||||
|
N("trial",55,330,225,"变参试跑与结果核对","更换关键词 对象 页面状态","在维护环境运行候选,核对返回对象、字段、来源和分支。","候选版本、变化参数与样本","实际执行及结果核对记录",["不是只跑通首个关键词","动态内容不要求两次结果完全相同"],"5.2、11"),
|
||||||
|
N("dual",55,520,225,"双端验证与覆盖确认","Windows + macOS 分别记录","按相同业务范围验证两端,同时记录浏览器版本与未覆盖情况。","试跑记录与目标环境","各端验证结论和兼容范围",["单端通过不能视为双端通过","关注私信用测试账号和测试对象验证"],"4.1、5.2–5.4"),
|
||||||
|
N("release",350,520,225,"发布不可变版本","版本号 · 内容校验 · 用例记录","将通过约定验收的候选纳入可用清单,保留原版本。","已验证候选和兼容信息","商家可用的能力版本",["变更生成新版本","不同能力版本使用关联新任务恢复进度"],"5.3、5.5、8"),
|
||||||
|
N("run",645,520,225,"商家固定执行","版本快照 · 参数 · 预算","商家任务重复调用已发布能力,AI 仍动态分析内容和采购意图。","已发布能力与商家条件","线索、动作结果与检查点",["不临时替换生产脚本","业务判断不冻结为某次学习结论"],"5.4、8"),
|
||||||
|
N("repair",940,520,225,"诊断 修订 回归","限定预算 · 新候选 · 再验证","对试跑失败或运行缺口分析原因,修订候选并回归受影响用例。","失败位置、版本、必要参数与证据","修订候选及回归结果",["运行异常通过诊断资料导出回流","结果未知先核对实际效果"],"4.3、5.5"),
|
||||||
|
N("records",350,695,520,"独立学习验证记录库","PostgreSQL · 校验 试跑 读回 双端验收留档","维护端独立保存每次验证的能力版本与校验值、用例、参数、系统与浏览器环境、前后状态、预期和实际结果、耗时、用量与证据引用。","每轮学习、校验和验证产物","可筛选回看的历史记录与发布依据",["重跑、修订追加记录,保留旧结论","与商家业务库隔离;大证据文件按引用保存"],"5.3","confirmed")
|
||||||
|
],
|
||||||
|
edges:[E("goal","observe"),E("observe","knowledge"),E("knowledge","gaps","缺口"),E("knowledge","candidate"),E("candidate","check","生成候选",{from:"left",to:"right"}),E("check","trial","校验通过",{from:"left",to:"right"}),E("trial","dual"),E("dual","release"),E("release","run"),E("run","repair","失败证据",{kind:"feedback"}),E("repair","candidate","修订后重验",{from:"top",to:"right",kind:"feedback",via:[[1052,450],[900,450],[900,367]],lx:980,ly:450}),E("trial","repair","试跑失败",{from:"bottom",to:"bottom",kind:"feedback",via:[[167,440],[300,440],[300,655],[1052,655]],lx:1010,ly:655}),E("check","candidate","拒绝后修订",{from:"top",to:"top",kind:"feedback",via:[[462,292],[757,292]],lx:605,ly:292})],
|
||||||
|
paths:[{name:"从观察到能力发布",ids:["goal","observe","knowledge","candidate","check","trial","dual","release","run"]},{name:"试跑失败后修订再验证",ids:["goal","observe","knowledge","candidate","check","trial","repair","candidate","check","trial","dual","release"]},{name:"候选校验拒绝后修订",ids:["candidate","check","candidate","check","trial","dual","release"]}],
|
||||||
|
notes:[["本轮进入学习后台基建","首期由维护者与本地 Codex 协作学习,探索并编写固定实现;后台接 PostgreSQL 与 Playwright,保存证据、候选及实测记录。后台自主学习 Agent 后续按需建设。"],["控制建设范围","先验证小红书搜索、详情与评论读取。全站分层探索、多工作者租约和跨端供给暂不引入;检索扩展已准备,业务索引尚未接入。"]]
|
||||||
|
},
|
||||||
|
{id:"ai",name:"AI 与额度",title:"一次分析请求如何计量与返回",description:"基础文本、复杂复核、视觉和维护学习分别配置。软件订阅、商家分析额度与供应商真实成本分别管理。",w:1220,h:842,
|
||||||
|
groups:[G(25,100,285,610,"商家电脑"),G(345,100,850,610,"云端网关与计量")],
|
||||||
|
nodes:[
|
||||||
|
N("content",50,155,235,"当前内容与商品条件","相关性 · 角色 · 采购意图","使用当前对象及必要上下文形成分析输入,三个判断分别记录。","标题、简介、评论和商品条件","待分析内容与用途",["文本优先,视觉按需","信息不足允许返回待判断"],"6.1–6.2"),
|
||||||
|
N("reuse",50,310,235,"去重与有效结果复用","上下文与版本参与复用键","先检查当前输入是否有仍有效的分析结果,再决定是否请求模型。","内容、商品、规则、模型与结构版本","已有结果或新分析请求",["上下文变化需重新判断","复用结果设置有效期"],"6.2–6.3"),
|
||||||
|
N("local",50,590,235,"保存分析与线索","结果依据 + 来源 + 用量","验证结果结构后写入本地记录,保留依据、模型和规则版本。","已完成分析结果或有效复用结果","本地分析记录、线索和客户端用量",["归属对象与原文依据对应","后续动作仍核对可靠身份与动作账目"],"6.1、7、9"),
|
||||||
|
N("gateway",375,310,235,"网关请求与幂等","业务用途 + 唯一请求标识","检查产品权益、请求和配置。已完成请求返回已有结果。","用途、必要内容、请求标识","可执行请求或已有结果",["重试复用原请求标识","不在客户端存供应商密钥"],"4.3、9"),
|
||||||
|
N("config",375,155,235,"运营模型配置","四类用途 独立预算","配置供应商、实际 model ID、协议、能力、价格和受限备用策略。","后台配置、质量和成本样本","经过验证的配置版本",["不要求使用最新或最强模型","任务保存配置快照"],"6.4","confirmed"),
|
||||||
|
N("reserve",670,310,235,"预占分析额度","校验权益与预算","在调用模型前预占本次请求额度,防止无预算开始调用。","请求标识、估算用量与余额","预占记录或等待额度",["余额不足不开始新的分析","预占与结算通过服务端账本管理"],"9"),
|
||||||
|
N("model",960,310,210,"模型调用","实际模型与用量标识","按配置调用已验证的模型,接收结果和可获得的实际用量。","结构化分析输入和用途预算","响应、请求标识与计量",["文字与视觉能力分别验证","备用模型回退也受预算限制"],"6.4、9"),
|
||||||
|
N("result",960,590,210,"结果与计量持久化","结构校验 · 响应保存","保存有效结果和实际供应商用量,再进行额度结算。","模型响应、实际计量","可复用结果与实际消耗记录",["结构不合格不进入有效线索","供应商成本与商家扣费分账记录"],"6.3、9"),
|
||||||
|
N("settle",670,590,235,"结算或释放预占","实际消耗与余额对账","依据结果和计量结算额度,释放多余预占,再返回已完成状态。","持久化结果、实际用量、预占记录","已结算请求与可返回结果",["已完成重试不再次扣费","客户端、服务端与供应商用量对账"],"9"),
|
||||||
|
N("return",375,590,235,"返回已完成结果","结构化判断与计量","将确认完成的结果交给商家端记录,异常保留明确状态。","结果、判断依据与计量","业务分析响应",["客户端按用途调用稳定接口","不硬编码供应商型号"],"4.3、6.4、9"),
|
||||||
|
N("uncertain",960,155,210,"超时 待核对","可能已经产生费用","调用超时可能已有供应商消耗,保留处理中或待核对状态。","未决请求与供应商记录","后续核对结果",["不立即按免费失败处理","不盲目发起另一笔调用"],"9")
|
||||||
|
],
|
||||||
|
edges:[E("content","reuse"),E("reuse","local","有效复用",{from:"bottom",to:"top"}),E("reuse","gateway","新分析"),E("config","gateway"),E("gateway","reserve"),E("reserve","model"),E("model","result","正常返回"),E("result","settle","保存后结算",{from:"left",to:"right"}),E("settle","return", "已完成",{from:"left",to:"right"}),E("return","local", "记录",{from:"left",to:"right"}),E("model","uncertain","超时",{from:"top",to:"bottom",kind:"feedback"}),E("uncertain","result","核对后补全",{from:"right",to:"right",kind:"feedback",via:[[1183,192],[1183,627]],lx:1150,ly:496}),E("gateway","return","已完成请求复用",{from:"bottom",to:"top",via:[[492,500]],lx:490,ly:484})],
|
||||||
|
paths:[{name:"新分析请求正常完成",ids:["content","reuse","gateway","reserve","model","result","settle","return","local"]},{name:"命中有效本地结果",ids:["content","reuse","local"]},{name:"供应商超时后核对",ids:["content","reuse","gateway","reserve","model","uncertain","result","settle","return","local"]},{name:"已完成请求重试",ids:["gateway","return","local"]}],
|
||||||
|
notes:[["三种账分开","软件有效期决定产品权益;商家分析额度决定可用分析量;供应商 Token 和视觉用量用于核算真实成本。套餐和价格还需实际样本验证。"],["学习成本与运行成本分开","维护者学习、探索和修复属于研发维护成本。商家任务中的文本分析、视觉分析和可选页面诊断属于运行用量。"]]
|
||||||
|
},
|
||||||
|
{id:"recovery",name:"任务恢复",title:"任务状态与动作结果分别管理",description:"上方描述任务是否继续,下方描述单次跟进动作的结果。任务重启、崩溃或超时,不能代替实际动作核对。",w:1220,h:932,
|
||||||
|
groups:[G(25,100,1170,425,"任务生命周期"),G(25,555,1170,300,"关注或私信动作账目")],
|
||||||
|
nodes:[
|
||||||
|
N("queued",65,160,210,"待运行","绑定版本与条件快照","任务保存商品条件、能力版本、模型配置、账号与预算。","商家任务设置","可运行任务快照",["新版本不悄悄进入已有任务","一个账号同一时刻一个任务持有控制权"],"8"),
|
||||||
|
N("running",435,160,250,"运行中","按确认步骤保存检查点","串行调用能力,记录已完成对象、剩余队列、步骤与动作状态。","任务快照与账号会话","进度、线索、动作账目",["检查点不依赖列表第 N 条位置","休眠或退出后本地任务不会继续运行"],"4.2、8"),
|
||||||
|
N("complete",945,160,210,"完成","达到本轮任务范围","按实际发现的对象和任务预算完成本轮,不代表平台内容全部枚举。","已处理对象和任务条件","任务报告与已用额度",["保留结果来源和模型计量","待核对动作不能伪装成已确认发送"],"7–8"),
|
||||||
|
N("pausing",65,300,210,"暂停中","等待可确认的步骤边界","收到暂停请求后在可确认边界停止推进,保存必要状态。","暂停请求与当前步骤","检查点与暂停结果",["紧急停止仍可能留下未知动作","接管前移交浏览器控制权"],"4.2、8"),
|
||||||
|
N("login",365,300,220,"等待登录","官方页面人工登录 再核验","会话复用检查失败时停止推进,由商家在官方页面自行扫码或输入验证码,核验账号与页面后恢复。","失效会话或需登录页面","确认身份的会话",["不在产品界面索取平台手机号和验证码","重新确认当前账号、对象与页面"],"4.2、8"),
|
||||||
|
N("quota",655,300,220,"等待额度","补足额度后再检查","模型预算或额度不足时停止开启新分析及依赖它的后续动作。","余额不足或用途预算耗尽","明确等待状态与检查点",["未决调用先核对和结算","恢复前重新检查预算"],"8–9"),
|
||||||
|
N("handling",945,300,210,"等待处理","接管或核对后恢复","页面变化、必要人工接管或未决状态先等待处理,再核验恢复条件;升级兼容检查不满足时保持暂停等待处理。","异常证据、兼容问题或人工接管","处理记录与恢复条件",["页面失配可导出诊断资料","不在任务中生成并热换新脚本"],"5.5、8"),
|
||||||
|
N("paused",65,425,210,"已暂停","检查点已保存","恢复时核对页面、账号和对象,再回到固定版本的任务。","已确认检查点与恢复请求","可继续的任务上下文",["旧版本仍有效才继续","换能力版本时建立关联新任务"],"5.5、8"),
|
||||||
|
N("failed",435,425,220,"失败","无法按恢复规则继续","确认不可恢复时记录失败,保留依据与诊断信息;升级兼容问题先进入暂停或等待处理。","异常与恢复检查结果","失败记录及后续修订输入",["失败与动作结果未知分开","保留已完成对象和动作账目"],"5.5、8"),
|
||||||
|
N("cancelled",775,425,220,"取消","停止后续推进","任务取消后不开始后续处理,已经发生或结果未知的动作继续保留记录。","取消请求","终止的任务与未决动作记录",["取消不是撤回已发生的操作","重建任务仍沿用跨任务动作去重"],"7–8"),
|
||||||
|
N("prepared",65,610,210,"prepared 已准备","目标与业务标识已记录","动作执行前写入可靠目标、业务跟进轮次及内容版本。","线索与跟进设置","动作业务标识和准备记录",["关注与发送分开建模","模板变化不自动产生新的跟进轮次"],"7"),
|
||||||
|
N("executing",365,610,220,"executing 执行中","正在尝试平台动作","执行关注或发送后核对实际状态,崩溃与超时不直接判定失败。","已准备动作与当前页面","待核对或已确认的结果",["先记录意图再执行动作","程序成功返回不等同于业务效果确认"],"7"),
|
||||||
|
N("confirmed",945,610,210,"confirmed 已确认","实际效果可核对","依据实际平台状态或可读回证据确认动作结果。","动作标识与结果证据","已确认动作状态",["关联实际目标和内容","已确认动作跨任务去重"],"7"),
|
||||||
|
N("uncertain",655,750,220,"uncertain 结果未知","先核对实际效果","网络中断、程序崩溃或页面超时后,先恢复页面并核对动作是否发生。","未决动作和对应页面","确认发生、确认未发生或仍未知",["未知不能直接当失败重试","仅靠本地标识不能保证第三方动作恰好一次"],"7"),
|
||||||
|
N("action-failed",365,750,220,"failed 已确认失败","保留原因与跟进状态","依据核对结果记录失败;后续是否执行需重新满足业务与去重条件。","确认失败或未发生的证据","失败记录与后续处理条件",["不从未知状态直接自动重发","新的跟进轮次必须是明确业务设置"],"7")
|
||||||
|
],
|
||||||
|
edges:[E("queued","running"),E("running","complete","范围完成"),E("running","pausing","暂停请求",{from:"bottom",fromAt:.12,to:"top",via:[[465,265],[170,265]],lx:215,ly:265}),E("pausing","paused"),E("paused","running","恢复前核验",{from:"left",to:"left",via:[[40,462],[40,245],[415,245],[415,197]],lx:190,ly:245}),E("running","login","失效 / 核验恢复",{from:"bottom",fromAt:.3,to:"top",bi:true,lx:465,ly:280}),E("running","quota","额度不足 / 恢复",{from:"bottom",fromAt:.68,to:"top",bi:true,lx:735,ly:280}),E("running","handling","异常 / 处理恢复",{from:"right",fromAt:.8,to:"top",via:[[890,219.2],[890,265],[1050,265]],bi:true,lx:1045,ly:265}),E("running","failed","不可恢复",{from:"bottom",fromAt:.74,to:"top",via:[[620,395],[545,395]],lx:605,ly:397,kind:"feedback"}),E("running","cancelled","取消",{from:"right",fromAt:.9,to:"top",via:[[905,226.6],[905,400],[885,400]],lx:905,ly:392}),E("prepared","executing"),E("executing","confirmed","效果可核对"),E("executing","uncertain","崩溃 / 超时",{from:"bottom",to:"top",via:[[475,720],[765,720]],lx:645,ly:720,kind:"feedback"}),E("uncertain","confirmed","核验已发生",{from:"right",to:"left",via:[[915,787],[915,647]],lx:916,ly:733,kind:"feedback"}),E("uncertain","action-failed","核验失败或未发生",{from:"bottom",to:"bottom",via:[[765,842],[475,842]],kind:"feedback",lx:620,ly:842})],
|
||||||
|
paths:[{name:"任务正常完成",ids:["queued","running","complete"]},{name:"登录失效后恢复",ids:["queued","running","login","running","complete"]},{name:"暂停后核验恢复",ids:["queued","running","pausing","paused","running","complete"]},{name:"动作结果未知后确认发生",ids:["prepared","executing","uncertain","confirmed"]},{name:"动作核验失败或未发生",ids:["prepared","executing","uncertain","action-failed"]}],
|
||||||
|
notes:[["版本恢复的明确边界","旧任务继续使用原能力版本。若必须换版本,先检查进度兼容性,再建立关联的新任务,继承已完成对象和动作账目。"],["人工接管与机器执行互斥","暂停与接管通过明确控制权移交。恢复后重新核验账号、页面和目标对象;跨任务去重减少重复,但不能替代实际结果核对。"]]
|
||||||
|
},
|
||||||
|
{id:"session",name:"登录会话",title:"用户自行登录 下次复用同一浏览器资料",description:"平台官方页面承接扫码与验证码登录。固定账号资料目录保存可持久化状态,每次任务重新核验会话和账号。",w:1220,h:842,
|
||||||
|
groups:[G(25,100,1170,650,"独立窗口为主线 · 内嵌宿主待验证 · 同账号固定目录或分区")],
|
||||||
|
nodes:[
|
||||||
|
N("profile",55,155,250,"分配账号专用会话","固定目录 或稳定 persist 分区","独立浏览器分配固定资料目录;若采用内嵌宿主,则分配稳定 Electron 分区。SQLite 保存资料引用、状态和核验时间。","平台、本地账号标识","专用浏览器资料和账号映射",["按账号固定,不追加新会话随机 ID","重启和升级保留,同账号控制权互斥"],"4.2–4.4"),
|
||||||
|
N("human",480,155,250,"商家在官方页面登录","扫码 或 官方手机号验证码表单","主线打开独立可最大化浏览器,由商家自行完成登录;内嵌方案须先验证现成宿主与固定执行器接入。业务执行器暂停动作。","未登录或需重新验证的页面","平台完成的人工登录",["我们的产品界面不索取或代收验证码","旧密码本准入需要替换为人工登录与身份核验"],"2、4.1–4.4","confirmed"),
|
||||||
|
N("verified",905,155,250,"核验登录与账号身份","页面标志 + 可靠账号标识","等待登录跳转完成,读取必要的登录标志和账号标识,再绑定或核对任务账号。","完成登录的当前页面","身份明确且可用的会话",["二维码消失或已有 Cookie 不能单独判定成功","身份不明或与预期账号不同则等待处理"],"4.2"),
|
||||||
|
N("task",55,345,250,"下一次启动任务","读取原账号映射","软件再次启动或新建平台任务时,读取原账号及固定浏览器目录。","已保存账号记录与任务","要复用的会话或专用目录",["SQLite 上次已登录不证明当前仍有效","浏览器资料保留在当前设备"],"4.2–4.3"),
|
||||||
|
N("reuse",480,345,250,"复用或重开浏览器","同一账号目录或持久化分区","优先复用已有会话;关闭后在原实例释放控制权后重开同一账号资料。内嵌执行器连接宿主真实页面目标。","原会话或固定账号资料引用","待核验的浏览器会话",["不为每次任务创建随机登录分区","人工登录和业务执行使用同一会话"],"4.2、4.4"),
|
||||||
|
N("probe",905,345,250,"检查当前会话","有效性与任务账号对应","打开页面检查登录状态、身份和必要页面条件。资料保留且平台会话有效时可直接执行;身份不明或账号不符先等待处理。","重开或现有浏览器会话","可执行、等待登录或等待处理",["身份不明不直接判为会话失效","不承诺永久免登录或固定有效天数","验证码、手机号和原始凭据不写入业务记录"],"4.2"),
|
||||||
|
N("closed",55,585,250,"关闭浏览器后暂停","保存检查点并释放目录","关闭整个浏览器会结束当前执行会话,运行端暂停;任务结束正常关闭时也保留账号目录。","浏览器关闭事件和当前任务","检查点、未决动作及保留的账号资料",["关闭浏览器不代表执行会继续","重新打开后再核验身份与页面"],"4.2、8"),
|
||||||
|
N("run",480,585,250,"同会话交给执行器","回到控制台 可最小化浏览器","登录核验完成后由执行器接管同一会话,提供显示浏览器和人工接管入口。","身份已核验的会话与任务","固定链路执行与任务进度",["最小化的运行效果在 Windows/macOS 分别验证","保留可见方式,不默认切换无头运行"],"4.1–4.2、8"),
|
||||||
|
N("expired",905,585,250,"会话失效 等待登录","到期 退出 清理 或 需重新验证","确认当前未登录或需要平台再次验证时,显示浏览器,由商家完成登录。","明确未登录、会话失效或需要重新验证的页面","明确等待状态与人工登录入口",["不自动操作验证码流程","重新登录成功后再核验账号并恢复"],"4.2、8")
|
||||||
|
],
|
||||||
|
edges:[E("profile","human"),E("human","verified","完成登录"),E("verified","run","同会话移交",{from:"bottom",to:"top",via:[[1030,270],[860,270],[860,530],[605,530]],lx:858,ly:494}),E("task","reuse","读取原目录"),E("reuse","probe","打开后核验"),E("probe","run","仍有效",{from:"bottom",to:"top",via:[[1030,500],[605,500]],lx:710,ly:500}),E("probe","expired","确认未登录或需验证"),E("expired","human","重新人工登录",{from:"right",to:"bottom",via:[[1180,622],[1180,285],[605,285]],kind:"feedback",lx:1100,ly:285}),E("run","closed","手动关闭则暂停",{from:"left",to:"right"}),E("closed","reuse","同目录重开",{from:"top",to:"left",via:[[180,475],[450,475],[450,382]],lx:305,ly:475})],
|
||||||
|
paths:[{name:"首次人工登录后运行",ids:["profile","human","verified","run"]},{name:"下次任务复用有效会话",ids:["task","reuse","probe","run"]},{name:"会话失效重新人工登录",ids:["task","reuse","probe","expired","human","verified","run"]},{name:"关闭浏览器后重开核验",ids:["run","closed","reuse","probe","run"]}],
|
||||||
|
notes:[["窗口形态与会话保存分别决定","主线仍为独立可最大化窗口,现有内嵌宿主列为首版待验证候选。两者都需保持登录与执行为同一会话,收起或最小化需双端验证;关闭执行浏览器则先暂停。"],["资料实现按宿主适配","独立浏览器使用固定目录,内嵌 Electron 使用稳定 persist 分区。SQLite 保存账号映射和核验状态;原工程的新会话随机标识与密码本准入需改造,不能直接提供跨任务扫码登录复用。"]]
|
||||||
|
},
|
||||||
|
{id:"delivery",name:"实施阶段",title:"按验证结果推进的六个阶段",description:"顺序表示依赖关系,没有预设交付日期或处理量承诺。双端基础验证与独立学习验证记录从第一阶段开始。",w:1220,h:832,
|
||||||
|
groups:[G(25,100,1170,660,"建议实施路线 · 已启动学习后台基建")],
|
||||||
|
nodes:[
|
||||||
|
N("foundation",65,155,475,"01 工程基础与单平台验证","学习后台迁入 · 独立存储 · 双端读取","迁入 A2A 学习后台适用模块,接本项目 PostgreSQL 和 Playwright,先验证本机示例,再适配小红书搜索、详情与评论读取。","适用源码、平台、测试账号、学习目标","独立基础骨架、可重复链路和学习验证记录",["独立应用目录、端口和进程归属,不依赖原校园服务","Windows 与 macOS 各自实测;首版尚未完成真实平台验收"],"5.8、10 阶段一","proposed",120),
|
||||||
|
N("discovery",680,155,475,"02 发现与筛选","商品档案 · 三类判断 · 线索与来源","实现任务与本地线索页面,用带上下文的标注样本比较人工判断。","基础读取能力与样本","误判、漏判、耗时和费用记录",["判断质量与成本分别测量","样本量和可接受标准首轮后细化"],"10 阶段二","proposed",120),
|
||||||
|
N("assets",680,350,475,"03 学习资产与恢复","校验 · 版本 · 回流 · 修订回归","完善候选校验、试跑记录、发布与回滚,验证常见中断和页面变化。","前期候选、学习资料与失败记录","统一能力包、版本工具和恢复验证",["实际证据和构造场景分别记录","自然失败修订闭环单独验收"],"10 阶段三","proposed",120),
|
||||||
|
N("followup",65,350,475,"04 跟进闭环","关注 · 私信 · 结果核对 · 回复","分别验证跟进动作、重复候选、发送中断及结果不明后核对,再确定首版开放范围。","可回看线索、测试对象与动作契约","跟进闭环与明确发布范围",["跟进验收不由只读流程通过替代","未知结果先核对,跨任务去重"],"10 阶段四","pending",120),
|
||||||
|
N("commercial",65,555,475,"05 可收费的双端安装版","安装升级 · 权益 · 后台模型 · 账本","完善管理界面、配置验证发布、设备权益和订单计量,两端分别验证安装生命周期。","已验证业务链路、客户端与服务端","可安装版本与用量对账结果",["模型配置逻辑在前期先以开发配置使用","最低系统版本和架构由依赖验证确定"],"10 阶段五","proposed",120),
|
||||||
|
N("expansion",680,555,475,"06 扩展覆盖","推荐流 · 多账号 · 新平台 · 手机查看","稳定单平台流程和商业用量后,依次评估多账号、新平台与可选同步。","稳定的单平台产品与需求反馈","新增适配及独立验证结果",["每增加一个平台都新增验证","手机查看与手机执行分别评估"],"10 阶段六","later",120)
|
||||||
|
],
|
||||||
|
edges:[E("foundation","discovery"),E("discovery","assets"),E("assets","followup","恢复能力支撑跟进",{from:"left",to:"right"}),E("followup","commercial"),E("commercial","expansion","基础流程与用量稳定",{kind:"optional"})],
|
||||||
|
paths:[{name:"从可行性验证到扩展",ids:["foundation","discovery","assets","followup","commercial","expansion"]}],
|
||||||
|
notes:[["基础模块提取与链路验证并行","桌面壳和后台基础按限定范围提取,云端可保留 FastAPI。学习证据与验证记录从原型开始保留;不以整套旧校园工程迁移完成为前提。"],["仍需验证的选择","首发平台、内嵌宿主是否采用、具体模型、跟进开放范围、套餐额度、并行规模和 Midscene 均待验证。Windows 原模板可参考,macOS 安装与发布仍需独立建设。"]]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const statusLabels={confirmed:"已确认方向 · 逐模块验收",proposed:"实施建议 · 逐模块验证",pending:"范围或效果待验证",later:"后续扩展 · 逐项验证"};
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const esc=value=>String(value).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||||
|
const state={tab:0,node:null,focus:false,zoom:1,scenario:0,step:-1,timer:null};
|
||||||
|
let toastTimer;
|
||||||
|
function validateDiagrams(){
|
||||||
|
const ids=new Set();
|
||||||
|
for(const d of diagrams){
|
||||||
|
if(ids.has(d.id))throw Error("重复图页 "+d.id);ids.add(d.id);
|
||||||
|
const nodes=new Map();
|
||||||
|
for(const n of d.nodes){if(nodes.has(n.id))throw Error("重复节点 "+n.id);nodes.set(n.id,n);if(n.x<0||n.y<0||n.x+n.w>d.w||n.y+n.h>d.h-40)throw Error("节点超出画布 "+d.id+"/"+n.id);if(!n.source||!n.desc)throw Error("缺失节点依据 "+n.id);}
|
||||||
|
for(const e of d.edges){if(!nodes.has(e.a)||!nodes.has(e.b))throw Error("关系引用无效 "+d.id);}
|
||||||
|
for(const p of d.paths){for(let i=0;i<p.ids.length;i++){if(!nodes.has(p.ids[i]))throw Error("路径节点无效");if(i&&!d.edges.some(e=>(e.a===p.ids[i-1]&&e.b===p.ids[i])||(e.bi&&e.b===p.ids[i-1]&&e.a===p.ids[i])))throw Error("路径关系缺失 "+d.id+" "+p.ids[i-1]+"→"+p.ids[i]);}}
|
||||||
|
for(let i=0;i<d.nodes.length;i++)for(let j=i+1;j<d.nodes.length;j++){const a=d.nodes[i],b=d.nodes[j];if(a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y)throw Error("节点重叠 "+d.id);}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function endpoint(n,side,at=.5){if(side==="top")return[n.x+n.w*at,n.y];if(side==="bottom")return[n.x+n.w*at,n.y+n.h];if(side==="left")return[n.x,n.y+n.h*at];return[n.x+n.w,n.y+n.h*at];}
|
||||||
|
function edgePoints(e,map){
|
||||||
|
const a=map.get(e.a),b=map.get(e.b);
|
||||||
|
const vertical=Math.abs(a.x+a.w/2-b.x-b.w/2)<45;
|
||||||
|
const from=e.from||(vertical?(b.y>a.y?"bottom":"top"):(b.x>a.x?"right":"left"));
|
||||||
|
const to=e.to||(vertical?(b.y>a.y?"top":"bottom"):(b.x>a.x?"left":"right"));
|
||||||
|
const start=endpoint(a,from,e.fromAt??.5),end=endpoint(b,to,e.toAt??.5);
|
||||||
|
let middle=e.via||[];
|
||||||
|
if(!e.via&&start[0]!==end[0]&&start[1]!==end[1]){
|
||||||
|
if(from==="bottom"||from==="top"){const y=(start[1]+end[1])/2;middle=[[start[0],y],[end[0],y]];}
|
||||||
|
else{const x=(start[0]+end[0])/2;middle=[[x,start[1]],[x,end[1]]];}
|
||||||
|
}
|
||||||
|
return[start,...middle,end];
|
||||||
|
}
|
||||||
|
function labelPosition(points){let longest=0,pos=points[0];for(let i=1;i<points.length;i++){const a=points[i-1],b=points[i],len=Math.hypot(b[0]-a[0],b[1]-a[1]);if(len>longest){longest=len;pos=[(a[0]+b[0])/2,(a[1]+b[1])/2];}}return pos;}
|
||||||
|
function lines(text,maxUnits){const result=[];let part="",units=0;for(const c of text){const width=/[\x00-\x7F]/.test(c)?.55:1;if(units+width>maxUnits&&part){result.push(part);part="";units=0;}part+=c;units+=width;}if(part)result.push(part);return result;}
|
||||||
|
function nodeMarkup(n){
|
||||||
|
const titleLines=lines(n.title,(n.w-30)/15),subLines=lines(n.sub,(n.w-30)/11);
|
||||||
|
const titleY=n.h>90?34:29;
|
||||||
|
const title=titleLines.map((line,i)=>`<tspan x="16" y="${titleY+i*20}">${esc(line)}</tspan>`).join("");
|
||||||
|
const subtitle=subLines.map((line,i)=>`<tspan x="16" y="${titleY+titleLines.length*20+3+i*16}">${esc(line)}</tspan>`).join("");
|
||||||
|
return `<g class="node" data-node="${esc(n.id)}" role="button" tabindex="0" aria-label="${esc(n.title)} 查看详情" transform="translate(${n.x} ${n.y})"><title>${esc(n.title+":"+n.desc)}</title><rect class="node-box" width="${n.w}" height="${n.h}" rx="8"/><text class="node-title">${title}</text><text class="node-sub">${subtitle}</text></g>`;
|
||||||
|
}
|
||||||
|
function buildSVG(d){
|
||||||
|
const map=new Map(d.nodes.map(n=>[n.id,n]));
|
||||||
|
const groups=d.groups.map(g=>`<g><rect class="group-rect" x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="10"/><text class="group-title" x="${g.x+17}" y="${g.y+25}">${esc(g.title)}</text></g>`).join("");
|
||||||
|
const edges=d.edges.map((e,i)=>{
|
||||||
|
const pts=edgePoints(e,map),path=pts.map((p,j)=>(j?"L":"M")+p.join(" ")).join(" ");
|
||||||
|
const marker=e.kind==="feedback"?"feedback":"normal";
|
||||||
|
return `<path class="edge ${e.kind||""}" data-edge="${i}" d="${path}" marker-end="url(#arrow-${marker})" ${e.bi?`marker-start="url(#arrow-${marker}-start)"`:""}><title>${esc(map.get(e.a).title+" → "+map.get(e.b).title+(e.label?":"+e.label:""))}</title></path>`;
|
||||||
|
}).join("");
|
||||||
|
const labels=d.edges.map((e,i)=>{if(!e.label)return"";const p=labelPosition(edgePoints(e,map)),x=e.lx??p[0],y=e.ly??p[1],w=lines(e.label,200)[0].split("").reduce((a,c)=>a+(/[\x00-\x7F]/.test(c)?5.5:10),0)+10;return `<g class="edge-label ${e.kind||""}" data-label="${i}"><rect x="${x-w/2}" y="${y-11}" width="${w}" height="19" rx="3"/><text x="${x}" y="${y+2}" text-anchor="middle">${esc(e.label)}</text></g>`;}).join("");
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${d.w} ${d.h}" width="${d.w}" height="${d.h}" aria-labelledby="svg-title svg-description" role="img"><title id="svg-title">${esc(d.title)}</title><desc id="svg-description">${esc(d.description)} 所有节点可通过键盘聚焦并查看详情。</desc><defs>${["normal","active","feedback"].map(c=>`<marker id="arrow-${c}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="arrow-tip ${c==="normal"?"":c}" d="M 1 1 L 9 5 L 1 9 Z"/></marker><marker id="arrow-${c}-start" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="arrow-tip ${c==="normal"?"":c}" d="M 1 1 L 9 5 L 1 9 Z"/></marker>`).join("")}</defs><rect class="paper" width="${d.w}" height="${d.h}"/><text class="svg-heading" x="28" y="38">${esc(d.title)}</text><text class="svg-sub" x="28" y="64">整体设计 · 实现状态见 README · 点击节点查看职责和来源</text>${groups}<g class="edges">${edges}</g><g class="labels">${labels}</g><g class="nodes">${d.nodes.map(nodeMarkup).join("")}</g><text class="svg-sub" x="28" y="${d.h-23}">来源:AI 获客助手整体方案 v0.2 · 2026-10-22</text><text class="svg-sub" x="${d.w-28}" y="${d.h-23}" text-anchor="end">${esc(d.name)} / ${d.nodes.length} 个节点</text></svg>`;
|
||||||
|
}
|
||||||
|
function renderTabs(){
|
||||||
|
$("tabs").innerHTML=diagrams.map((d,i)=>`<button type="button" class="tab" id="tab-${d.id}" role="tab" aria-selected="${i===state.tab}" aria-controls="viewport" tabindex="${i===state.tab?0:-1}" data-tab="${i}"><span class="index">${String(i+1).padStart(2,"0")}</span>${esc(d.name)}</button>`).join("");
|
||||||
|
}
|
||||||
|
function render(){
|
||||||
|
const d=diagrams[state.tab];renderTabs();
|
||||||
|
$("diagram-title").textContent=d.title;$("diagram-description").textContent=d.description;$("diagram-count").textContent=`${d.nodes.length} 节点 / ${d.edges.length} 关系`;
|
||||||
|
$("viewport").innerHTML=buildSVG(d);$("viewport").setAttribute("aria-labelledby","tab-"+d.id);
|
||||||
|
$("scenario").innerHTML=d.paths.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join("");$("scenario").value=String(state.scenario);
|
||||||
|
d.notes.forEach((n,i)=>{$("note-title-"+(i?"b":"a")).textContent=n[0];$("note-"+(i?"b":"a")).textContent=n[1];});
|
||||||
|
renderDetail();renderHighlights();renderZoom();
|
||||||
|
}
|
||||||
|
function renderDetail(){
|
||||||
|
const d=diagrams[state.tab],n=d.nodes.find(n=>n.id===state.node)||d.nodes[0];
|
||||||
|
$("detail").innerHTML=`<div class="detail-kicker">${esc(d.name)} / 节点职责</div><h3>${esc(n.title)}</h3><span class="status-tag">${esc(statusLabels[n.status])}</span><p class="detail-desc">${esc(n.desc)}</p><dl><dt>输入与前提</dt><dd>${esc(n.input)}</dd><dt>产出或状态</dt><dd>${esc(n.output)}</dd><dt>核对要点</dt><dd><ul>${n.checks.map(s=>`<li>${esc(s)}</li>`).join("")}</ul></dd></dl><div class="detail-source">来源:整体方案 §${esc(n.source)}<br><a href="整体方案-v0.2.md">查看完整文字方案 ↗</a></div>`;
|
||||||
|
}
|
||||||
|
function renderHighlights(){
|
||||||
|
const d=diagrams[state.tab],p=d.paths[state.scenario],route=state.step>=0?p.ids.slice(0,state.step+1):[],current=state.step>=0?p.ids[state.step]:null;
|
||||||
|
const neighbors=new Set([state.node]);for(const e of d.edges){if(e.a===state.node)neighbors.add(e.b);if(e.b===state.node)neighbors.add(e.a);}
|
||||||
|
$("viewport").querySelectorAll("[data-node]").forEach(el=>{const id=el.dataset.node;el.classList.toggle("selected",id===state.node);el.classList.toggle("route",route.includes(id));el.classList.toggle("current",id===current);el.classList.toggle("dim",state.focus&&Boolean(state.node)&&!neighbors.has(id));el.setAttribute("aria-pressed",String(id===state.node));});
|
||||||
|
d.edges.forEach((e,i)=>{
|
||||||
|
const highlighted=route.some((id,j)=>j&&((route[j-1]===e.a&&id===e.b)||(e.bi&&route[j-1]===e.b&&id===e.a)));
|
||||||
|
const dim=state.focus&&Boolean(state.node)&&e.a!==state.node&&e.b!==state.node;
|
||||||
|
const el=$("viewport").querySelector(`[data-edge="${i}"]`);el.classList.toggle("highlight",highlighted);el.classList.toggle("dim",dim);el.setAttribute("marker-end",`url(#arrow-${highlighted?"active":e.kind==="feedback"?"feedback":"normal"})`);if(e.bi)el.setAttribute("marker-start",`url(#arrow-${highlighted?"active":e.kind==="feedback"?"feedback":"normal"}-start)`);
|
||||||
|
$("viewport").querySelector(`[data-label="${i}"]`)?.classList.toggle("dim",dim);
|
||||||
|
});
|
||||||
|
$("step-label").innerHTML=state.step<0?"设计路径,尚未开始演示":`${state.step+1} / ${p.ids.length} · <b>${esc(d.nodes.find(n=>n.id===current).title)}</b>`;
|
||||||
|
$("prev").disabled=state.step<=0;$("next").disabled=state.step>=p.ids.length-1;$("play").textContent=state.timer?"暂停":"播放";
|
||||||
|
}
|
||||||
|
function renderZoom(){const svg=$("viewport").querySelector("svg");svg.style.width=state.zoom*100+"%";svg.style.minWidth=Math.round(820*state.zoom)+"px";$("zoom-value").textContent=Math.round(state.zoom*100)+"%";$("zoom-out").disabled=state.zoom<=.6;$("zoom-in").disabled=state.zoom>=2;}
|
||||||
|
function stop(){if(state.timer){clearInterval(state.timer);state.timer=null;}$("play").textContent="播放";}
|
||||||
|
function selectNode(id){state.node=id;renderDetail();renderHighlights();}
|
||||||
|
function setTab(index,updateHash=true){stop();state.tab=index;state.node=null;state.step=-1;state.scenario=0;state.zoom=1;render();$("viewport").scrollTo(0,0);if(updateHash){try{history.replaceState(null,"","#"+diagrams[index].id);}catch{location.hash=diagrams[index].id;}}}
|
||||||
|
function stepTo(index){const p=diagrams[state.tab].paths[state.scenario];state.step=Math.max(-1,Math.min(index,p.ids.length-1));if(state.step>=0)state.node=p.ids[state.step];renderDetail();renderHighlights();}
|
||||||
|
function toast(message){clearTimeout(toastTimer);$("notice").textContent=message;toastTimer=setTimeout(()=>{$("notice").textContent="";},3200);}
|
||||||
|
function download(blob,filename){const url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download=filename;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000);}
|
||||||
|
function exportSVG(){
|
||||||
|
const original=$("viewport").querySelector("svg"),copy=original.cloneNode(true);
|
||||||
|
const from=[original,...original.querySelectorAll("*")],to=[copy,...copy.querySelectorAll("*")];
|
||||||
|
const properties=["fill","stroke","stroke-width","stroke-dasharray","stroke-linecap","stroke-linejoin","font-family","font-size","font-weight","text-anchor"];
|
||||||
|
from.forEach((el,i)=>{const css=getComputedStyle(el);properties.forEach(p=>{const value=css.getPropertyValue(p);if(value)to[i].style.setProperty(p,value);});to[i].style.opacity="1";to[i].removeAttribute("tabindex");to[i].removeAttribute("aria-pressed");to[i].removeAttribute("role");});
|
||||||
|
const d=diagrams[state.tab];copy.setAttribute("width",d.w);copy.setAttribute("height",d.h);copy.style.removeProperty("width");copy.style.removeProperty("min-width");copy.style.removeProperty("height");
|
||||||
|
const xml=new XMLSerializer().serializeToString(copy);return new Blob([xml],{type:"image/svg+xml;charset=utf-8"});
|
||||||
|
}
|
||||||
|
async function exportPNG(){
|
||||||
|
const blob=exportSVG(),url=URL.createObjectURL(blob),image=new Image(),d=diagrams[state.tab];
|
||||||
|
try{await new Promise((resolve,reject)=>{image.onload=resolve;image.onerror=()=>reject(Error("无法生成图片"));image.src=url;});const canvas=document.createElement("canvas"),ratio=2;canvas.width=d.w*ratio;canvas.height=d.h*ratio;const context=canvas.getContext("2d");if(!context)throw Error("浏览器不支持图片导出");context.scale(ratio,ratio);context.drawImage(image,0,0);const output=await new Promise(resolve=>canvas.toBlob(resolve,"image/png"));if(!output)throw Error("图片生成失败");download(output,"assistant-"+d.id+".png");toast("PNG 已导出,包含当前主题和整张图");}finally{URL.revokeObjectURL(url);}
|
||||||
|
}
|
||||||
|
$("tabs").addEventListener("click",event=>{const button=event.target.closest("[data-tab]");if(button)setTab(Number(button.dataset.tab));});
|
||||||
|
$("tabs").addEventListener("keydown",event=>{if(!["ArrowLeft","ArrowRight","Home","End"].includes(event.key))return;event.preventDefault();let next=state.tab;if(event.key==="Home")next=0;else if(event.key==="End")next=diagrams.length-1;else next=(next+(event.key==="ArrowRight"?1:-1)+diagrams.length)%diagrams.length;setTab(next);$("tab-"+diagrams[next].id).focus();});
|
||||||
|
$("viewport").addEventListener("click",event=>{const node=event.target.closest("[data-node]");if(node)selectNode(node.dataset.node);});
|
||||||
|
$("viewport").addEventListener("keydown",event=>{if((event.key==="Enter"||event.key===" ")&&event.target.matches("[data-node]")){event.preventDefault();selectNode(event.target.dataset.node);}});
|
||||||
|
$("focus").addEventListener("change",event=>{state.focus=event.target.checked;if(state.focus&&!state.node)state.node=diagrams[state.tab].nodes[0].id;renderDetail();renderHighlights();});
|
||||||
|
$("zoom-in").addEventListener("click",()=>{state.zoom=Math.min(2,Math.round((state.zoom+.2)*10)/10);renderZoom();});
|
||||||
|
$("zoom-out").addEventListener("click",()=>{state.zoom=Math.max(.6,Math.round((state.zoom-.2)*10)/10);renderZoom();});
|
||||||
|
$("fit").addEventListener("click",()=>{state.zoom=1;renderZoom();$("viewport").scrollTo(0,0);});
|
||||||
|
$("scenario").addEventListener("change",event=>{stop();state.scenario=Number(event.target.value);state.step=-1;state.node=null;renderDetail();renderHighlights();});
|
||||||
|
$("next").addEventListener("click",()=>{stop();stepTo(state.step+1);});$("prev").addEventListener("click",()=>{stop();stepTo(state.step-1);});
|
||||||
|
$("reset").addEventListener("click",()=>{stop();state.node=null;stepTo(-1);});
|
||||||
|
$("play").addEventListener("click",()=>{if(state.timer){stop();return;}const path=diagrams[state.tab].paths[state.scenario];if(state.step>=path.ids.length-1)state.step=-1;stepTo(state.step+1);state.timer=setInterval(()=>{if(state.step>=path.ids.length-1){stop();renderHighlights();}else stepTo(state.step+1);},1450);renderHighlights();});
|
||||||
|
$("export-svg").addEventListener("click",()=>{try{download(exportSVG(),"assistant-"+diagrams[state.tab].id+".svg");toast("SVG 已导出,可继续编辑和放大");}catch(error){toast("导出失败:"+error.message);}});
|
||||||
|
$("export-png").addEventListener("click",async()=>{const button=$("export-png");button.disabled=true;try{await exportPNG();}catch(error){toast("导出失败:"+error.message);}finally{button.disabled=false;}});
|
||||||
|
function setTheme(theme){document.documentElement.dataset.theme=theme;$("theme").textContent=theme==="dark"?"切换浅色":"切换深色";$("theme").setAttribute("aria-label",$("theme").textContent);try{localStorage.setItem("assistant-plan-theme",theme);}catch{}}
|
||||||
|
$("theme").addEventListener("click",()=>setTheme(document.documentElement.dataset.theme==="dark"?"light":"dark"));
|
||||||
|
$("search").addEventListener("input",()=>{const query=$("search").value.trim().toLocaleLowerCase();$("results").replaceChildren();if(!query){$("search").setAttribute("aria-expanded","false");return;}const found=[];diagrams.forEach((d,index)=>d.nodes.forEach(n=>{if((n.title+" "+n.sub+" "+n.desc).toLocaleLowerCase().includes(query))found.push({d,index,n});}));found.slice(0,9).forEach(({d,index,n})=>{const button=document.createElement("button");button.type="button";button.className="result";const small=document.createElement("small");small.textContent=d.name;button.append(small,document.createTextNode(n.title));button.addEventListener("click",()=>{setTab(index);selectNode(n.id);$("search").value="";$("results").replaceChildren();$("search").setAttribute("aria-expanded","false");$("viewport").querySelector(`[data-node="${n.id}"]`).focus({preventScroll:true});});$("results").append(button);});if(!found.length){const empty=document.createElement("div");empty.className="search-empty";empty.textContent="没有匹配节点,试试 证据、模型 或 版本";$("results").append(empty);}$("search").setAttribute("aria-expanded","true");});
|
||||||
|
document.addEventListener("click",event=>{if(!event.target.closest(".search-box")){$("results").replaceChildren();$("search").setAttribute("aria-expanded","false");}});
|
||||||
|
$("search").addEventListener("keydown",event=>{if(event.key==="Escape"){$("search").value="";$("results").replaceChildren();$("search").setAttribute("aria-expanded","false");}if(event.key==="ArrowDown"){$("results").querySelector("button")?.focus();event.preventDefault();}});
|
||||||
|
window.addEventListener("hashchange",()=>{const index=diagrams.findIndex(d=>"#"+d.id===location.hash);if(index>=0&&index!==state.tab)setTab(index,false);});
|
||||||
|
document.addEventListener("visibilitychange",()=>{if(document.hidden)stop();});
|
||||||
|
validateDiagrams();
|
||||||
|
try{const saved=localStorage.getItem("assistant-plan-theme");setTheme(saved||"light");}catch{setTheme("light");}
|
||||||
|
const initial=diagrams.findIndex(d=>"#"+d.id===location.hash);if(initial>=0)state.tab=initial;
|
||||||
|
render();
|
||||||
|
// 供本地回归检查使用,不调用任何平台或模型接口。
|
||||||
|
window.assistantPlan={diagrams,validate:validateDiagrams,exportSVG};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Only run by a new assistant-learning Compose volume. No source project DBs.
|
||||||
|
CREATE DATABASE assistant_retrieval OWNER assistant;
|
||||||
|
CREATE DATABASE assistant_learning_test OWNER assistant;
|
||||||
|
|
||||||
|
\connect assistant_retrieval
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_search;
|
||||||
|
|
||||||
|
\connect assistant_learning_test
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_search;
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "assistant-workspace",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/dev.mjs",
|
||||||
|
"dev:server": "node scripts/server.mjs",
|
||||||
|
"dev:admin": "pnpm --dir apps/admin dev",
|
||||||
|
"build": "pnpm --dir apps/admin build",
|
||||||
|
"check": "pnpm --dir apps/admin check",
|
||||||
|
"db:up": "node scripts/db.mjs up",
|
||||||
|
"db:status": "node scripts/db.mjs status",
|
||||||
|
"db:check": "node scripts/check-postgres.mjs",
|
||||||
|
"db:stop": "node scripts/db.mjs stop",
|
||||||
|
"db:migrate-sqlite": "node scripts/migrate-learning.mjs",
|
||||||
|
"test:server": "node scripts/test-server.mjs",
|
||||||
|
"test:learning": "node scripts/test-learning.mjs"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=22.12" },
|
||||||
|
"dependencies": { "playwright": "1.58.2" }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- apps/admin
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { pythonPath, root } from './server.mjs';
|
||||||
|
|
||||||
|
const child = spawn(pythonPath(), ['-m', 'scripts.check_postgres'], {
|
||||||
|
cwd: root, env: { ...process.env, PYTHONUTF8: '1' }, stdio: 'inherit', windowsHide: true,
|
||||||
|
});
|
||||||
|
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
|
||||||
|
child.on('exit', code => { process.exitCode = code || 0; });
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Real extension smoke test in a new disposable schema of the test database.
|
||||||
|
|
||||||
|
The 3D vectors are synthetic fixtures, not model embeddings or a recall test.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg import sql
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
url = os.environ.get("ASSISTANT_TEST_DATABASE_URL", "")
|
||||||
|
if not url:
|
||||||
|
raise SystemExit("Run pnpm db:up to configure the isolated PostgreSQL test database.")
|
||||||
|
schema = "assistant_search_smoke_" + uuid4().hex
|
||||||
|
with psycopg.connect(url, connect_timeout=5) as connection:
|
||||||
|
major = int(connection.execute("SHOW server_version_num").fetchone()[0]) // 10000
|
||||||
|
extensions = dict(connection.execute("SELECT extname,extversion FROM pg_extension WHERE extname IN ('pg_search','vector')"))
|
||||||
|
assert major == 17 and extensions == {"pg_search": "0.26.1", "vector": "0.8.6"}, "PostgreSQL extension versions differ from the pinned baseline."
|
||||||
|
connection.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
|
||||||
|
connection.commit()
|
||||||
|
try:
|
||||||
|
connection.execute(sql.SQL("SET search_path TO {},public,pg_catalog").format(sql.Identifier(schema)))
|
||||||
|
connection.execute("CREATE TABLE documents(id bigint PRIMARY KEY, platform text NOT NULL, body text NOT NULL, embedding vector(3) NOT NULL)")
|
||||||
|
for row in [(1, 'xiaohongshu', '建材采购 搜索笔记 查看详情 读取客户评论', '[1,0,0]'),
|
||||||
|
(2, 'douyin', '建材装修 视频作者 主页读取', '[0.6,0.8,0]'),
|
||||||
|
(3, 'xiaohongshu', '蛋糕烘焙 菜谱教学', '[0,0,1]')]:
|
||||||
|
connection.execute("INSERT INTO documents VALUES(%s,%s,%s,%s::vector)", row)
|
||||||
|
connection.execute("CREATE INDEX documents_bm25 ON documents USING paradedb (id,(body::pdb.jieba),(platform::pdb.literal))")
|
||||||
|
connection.execute("ANALYZE documents")
|
||||||
|
connection.commit()
|
||||||
|
lexical = connection.execute("SELECT id,pdb.score(id) FROM documents WHERE body ||| %s AND platform=%s ORDER BY pdb.score(id) DESC,id LIMIT 3", ('建材', 'xiaohongshu')).fetchall()
|
||||||
|
assert lexical and [row[0] for row in lexical] == [1] and lexical[0][1] > 0, "Chinese BM25 scope/ranking mismatch."
|
||||||
|
plan = connection.execute("EXPLAIN (FORMAT JSON) SELECT id,pdb.score(id) FROM documents WHERE body ||| %s ORDER BY pdb.score(id) DESC LIMIT 3", ('建材',)).fetchone()[0]
|
||||||
|
assert 'Custom Scan' in str(plan) and ('ParadeDB' in str(plan) or 'Search' in str(plan)), "BM25 did not use its search index."
|
||||||
|
nearest = connection.execute("SELECT id FROM documents WHERE platform=%s ORDER BY embedding <=> %s::vector,id LIMIT 1", ('xiaohongshu', '[1,0,0]')).fetchone()[0]
|
||||||
|
assert nearest == 1, "Fixture vector ordering mismatch."
|
||||||
|
print(json.dumps({"result": "passed", "postgres_major": major, "extensions": extensions,
|
||||||
|
"chinese_bm25": True, "platform_filter": True, "search_index_plan": True,
|
||||||
|
"fixture_vector_ordering": True, "real_embedding_service": False}))
|
||||||
|
finally:
|
||||||
|
connection.rollback()
|
||||||
|
connection.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except psycopg.Error:
|
||||||
|
raise SystemExit("PostgreSQL extension check failed; check the local container and connection configuration (driver details withheld).") from None
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { root } from './server.mjs';
|
||||||
|
|
||||||
|
const envPath = path.join(root, '.env');
|
||||||
|
const command = process.argv[2] || 'up';
|
||||||
|
if (!['up', 'status', 'stop'].includes(command)) throw new Error('Use db.mjs up | status | stop');
|
||||||
|
function addLocalValue(key, value) {
|
||||||
|
if (process.env[key]) return;
|
||||||
|
const contents = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
|
||||||
|
// An explicit existing setting is owned by the user, even if left empty.
|
||||||
|
if (new RegExp(`^${key}=`, 'm').test(contents)) throw new Error(`${key} is empty in .env; complete the local database configuration.`);
|
||||||
|
fs.appendFileSync(envPath, `${contents && !contents.endsWith('\n') ? '\n' : ''}${key}=${value}\n`, { mode: 0o600 });
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
if (command === 'up') {
|
||||||
|
addLocalValue('ASSISTANT_POSTGRES_PASSWORD', randomBytes(32).toString('hex'));
|
||||||
|
addLocalValue('ASSISTANT_POSTGRES_PORT', '55432');
|
||||||
|
const base = `postgresql://assistant:${encodeURIComponent(process.env.ASSISTANT_POSTGRES_PASSWORD)}@127.0.0.1:${process.env.ASSISTANT_POSTGRES_PORT}`;
|
||||||
|
addLocalValue('ASSISTANT_DATABASE_URL', `${base}/assistant_learning`);
|
||||||
|
addLocalValue('ASSISTANT_DATABASE_SCHEMA', 'assistant_learning');
|
||||||
|
addLocalValue('ASSISTANT_RETRIEVAL_DATABASE_URL', `${base}/assistant_retrieval`);
|
||||||
|
addLocalValue('ASSISTANT_TEST_DATABASE_URL', `${base}/assistant_learning_test`);
|
||||||
|
addLocalValue('ASSISTANT_BROWSER_CHANNEL', process.platform === 'win32' ? 'msedge' : 'chromium');
|
||||||
|
}
|
||||||
|
const args = ['compose', '--progress', 'quiet', '--project-directory', root, '--file', path.join(root, 'compose.yaml')];
|
||||||
|
args.push(...(command === 'up' ? ['up', '-d', '--wait', '--wait-timeout', '120'] : command === 'stop' ? ['stop'] : ['ps']));
|
||||||
|
const result = spawnSync('docker', args, { cwd: root, env: process.env, stdio: 'inherit', windowsHide: true });
|
||||||
|
if (result.error) throw new Error('Docker CLI is unavailable; install and start Docker Desktop.');
|
||||||
|
process.exitCode = result.status ?? 1;
|
||||||
|
if (process.exitCode === 0 && command === 'up') console.log('Assistant PostgreSQL is ready. Local connection settings are saved in the ignored .env file.');
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { startServer, root } from './server.mjs';
|
||||||
|
import { stopOwnedChild } from './processes.mjs';
|
||||||
|
|
||||||
|
const backend = startServer();
|
||||||
|
const frontend = spawn(process.execPath, [path.join(root, 'apps/admin/node_modules/vite/bin/vite.js'), '--config', path.join(root, 'apps/admin/vite.config.ts')], {
|
||||||
|
cwd: root, env: process.env, stdio: 'inherit', windowsHide: true,
|
||||||
|
});
|
||||||
|
let stopping = false;
|
||||||
|
async function stop(code = 0) {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
await Promise.all([stopOwnedChild(backend, { graceful: true }), stopOwnedChild(frontend, { timeout: 3000 })]);
|
||||||
|
process.exitCode = code;
|
||||||
|
}
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => stop());
|
||||||
|
for (const child of [backend, frontend]) {
|
||||||
|
child.on('error', error => { console.error(error.message); stop(1); });
|
||||||
|
child.on('exit', code => { if (!stopping) stop(code || 0); });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { pythonPath, root } from './server.mjs';
|
||||||
|
|
||||||
|
const child = spawn(pythonPath(), ['-m', 'scripts.migrate_learning'], {
|
||||||
|
cwd: root, env: { ...process.env, PYTHONUTF8: '1' }, stdio: 'inherit', windowsHide: true,
|
||||||
|
});
|
||||||
|
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
|
||||||
|
child.on('exit', code => { process.exitCode = code || 0; });
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Copy this project's local learning records into an empty PostgreSQL schema.
|
||||||
|
|
||||||
|
The SQLite original and a consistent backup remain intact. Connection settings
|
||||||
|
come from the owning Node launcher, never command-line password arguments.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from apps.server.db import PostgresStore
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
TABLES = ("operators", "auth_attempts", "auth_sessions", "learning_tasks", "observations",
|
||||||
|
"learning_steps", "candidates", "validations", "artifacts")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
database_url = os.environ.get("ASSISTANT_DATABASE_URL", "")
|
||||||
|
if not database_url:
|
||||||
|
raise SystemExit("PostgreSQL is not configured. Run pnpm db:up first.")
|
||||||
|
home = Path(os.environ.get("ASSISTANT_LEARNING_HOME", ROOT / ".runtime" / "learning")).resolve()
|
||||||
|
original = home / "learning.sqlite"
|
||||||
|
if not original.is_file():
|
||||||
|
print(json.dumps({"result": "no_sqlite_source", "message": "Fresh PostgreSQL installation; no local records to copy."}))
|
||||||
|
return
|
||||||
|
target = PostgresStore(home, database_url, schema=os.environ.get("ASSISTANT_DATABASE_SCHEMA", "assistant_learning"))
|
||||||
|
backups = home / "backups"
|
||||||
|
backups.mkdir(exist_ok=True)
|
||||||
|
backup = backups / ("learning-before-pg-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + ".sqlite")
|
||||||
|
with sqlite3.connect(original.as_uri() + "?mode=ro", uri=True) as live:
|
||||||
|
with sqlite3.connect(backup) as copy:
|
||||||
|
live.backup(copy)
|
||||||
|
# The exact backup is the migration source, so counts and copied records
|
||||||
|
# cannot diverge from it even if the original is opened elsewhere later.
|
||||||
|
with sqlite3.connect(backup.as_uri() + "?mode=ro", uri=True) as source:
|
||||||
|
source.row_factory = sqlite3.Row
|
||||||
|
counts = {}
|
||||||
|
with target.connect(immediate=True) as destination:
|
||||||
|
if any(destination.execute(f"SELECT 1 FROM {table} LIMIT 1").fetchone() for table in TABLES):
|
||||||
|
raise SystemExit("PostgreSQL target already contains records. Migration stopped without replacing existing data.")
|
||||||
|
for table in TABLES:
|
||||||
|
columns = [row["name"] for row in source.execute(f"PRAGMA table_info({table})")]
|
||||||
|
rows = source.execute(f"SELECT * FROM {table} ORDER BY rowid").fetchall()
|
||||||
|
# Names are from this project's fixed local schema, not HTTP input.
|
||||||
|
names = ",".join('"' + name.replace('"', '""') + '"' for name in columns)
|
||||||
|
parameters = ",".join(f"${i}" for i in range(1, len(columns) + 1))
|
||||||
|
for row in rows:
|
||||||
|
destination.execute(f"INSERT INTO {table}({names}) VALUES({parameters})", tuple(row))
|
||||||
|
counts[table] = len(rows)
|
||||||
|
for table, count in counts.items():
|
||||||
|
if destination.execute(f"SELECT count(*) AS n FROM {table}").fetchone()["n"] != count:
|
||||||
|
raise RuntimeError("Migration count mismatch; transaction rolled back.")
|
||||||
|
print(json.dumps({"result": "copied", "counts": counts, "backup": str(backup), "source_preserved": True}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
|
// Only handles child objects created by this process. No port/name-based kills.
|
||||||
|
export async function stopOwnedChild(child, { graceful = false, timeout = 20000 } = {}) {
|
||||||
|
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
const exited = new Promise(resolve => child.once('exit', resolve));
|
||||||
|
if (graceful && child.stdin && !child.stdin.destroyed) {
|
||||||
|
child.stdin.on('error', () => {});
|
||||||
|
child.stdin.end();
|
||||||
|
} else {
|
||||||
|
child.kill();
|
||||||
|
}
|
||||||
|
await Promise.race([exited, delay(timeout, undefined, { ref: false })]);
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill();
|
||||||
|
await Promise.race([exited, delay(3000, undefined, { ref: false })]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Project-owned Uvicorn launcher with a parent-process control pipe.
|
||||||
|
|
||||||
|
Closing stdin requests a normal lifespan shutdown on Windows and macOS alike.
|
||||||
|
There is no HTTP shutdown endpoint and no lookup of unrelated processes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--port", type=int, default=8810)
|
||||||
|
args = parser.parse_args()
|
||||||
|
server = uvicorn.Server(uvicorn.Config("apps.server.main:app", host="127.0.0.1", port=args.port,
|
||||||
|
timeout_graceful_shutdown=8))
|
||||||
|
|
||||||
|
def watch_parent() -> None:
|
||||||
|
try:
|
||||||
|
# os.read avoids a daemon thread holding Python's buffered stdin lock
|
||||||
|
# during interpreter shutdown. Any parent message or EOF means stop.
|
||||||
|
os.read(sys.stdin.fileno(), 1024)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
server.should_exit = True
|
||||||
|
|
||||||
|
threading.Thread(target=watch_parent, name="assistant-parent-pipe", daemon=True).start()
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { stopOwnedChild } from './processes.mjs';
|
||||||
|
|
||||||
|
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
if (fs.existsSync(path.join(root, '.env'))) process.loadEnvFile(path.join(root, '.env'));
|
||||||
|
export function pythonPath() {
|
||||||
|
const executable = process.env.ASSISTANT_PYTHON || path.join(root, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
|
||||||
|
if (!fs.existsSync(executable)) throw new Error('请先建立 .venv 并安装 apps/server/requirements.txt 中的依赖。');
|
||||||
|
return executable;
|
||||||
|
}
|
||||||
|
export function startServer() {
|
||||||
|
return spawn(pythonPath(), ['-m', 'scripts.serve', '--port', process.env.ASSISTANT_API_PORT || '8810'], {
|
||||||
|
cwd: root,
|
||||||
|
env: { ...process.env, PYTHONUTF8: '1', ASSISTANT_NODE: process.execPath },
|
||||||
|
stdio: ['pipe', 'inherit', 'inherit'], windowsHide: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const child = startServer();
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => void stopOwnedChild(child, { graceful: true }));
|
||||||
|
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
|
||||||
|
child.on('exit', code => { process.exitCode = code || 0; });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
import { pythonPath, root } from './server.mjs';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { stopOwnedChild } from './processes.mjs';
|
||||||
|
|
||||||
|
// This acceptance run only uses isolated local fixture pages and its own processes.
|
||||||
|
const run = path.join(root, '.tmp', `learning-acceptance-${Date.now()}`);
|
||||||
|
fs.mkdirSync(run, { recursive: true });
|
||||||
|
async function unusedPort() {
|
||||||
|
const listener = createServer();
|
||||||
|
await new Promise((resolve, reject) => { listener.once('error', reject); listener.listen(0, '127.0.0.1', resolve); });
|
||||||
|
const port = listener.address().port;
|
||||||
|
await new Promise(resolve => listener.close(resolve));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
const apiPort = Number(process.env.ASSISTANT_TEST_API_PORT || await unusedPort());
|
||||||
|
let uiPort = Number(process.env.ASSISTANT_TEST_UI_PORT || await unusedPort());
|
||||||
|
if (uiPort === apiPort) uiPort = await unusedPort();
|
||||||
|
const apiBase = `http://127.0.0.1:${apiPort}`;
|
||||||
|
const uiBase = `http://127.0.0.1:${uiPort}`;
|
||||||
|
const environment = {
|
||||||
|
...process.env,
|
||||||
|
// Never inherit the actual development database loaded from .env.
|
||||||
|
ASSISTANT_DATABASE_URL: process.env.ASSISTANT_TEST_DATABASE_URL || '',
|
||||||
|
ASSISTANT_DATABASE_SCHEMA: 'assistant_learning_e2e_' + randomUUID().replaceAll('-', ''),
|
||||||
|
PYTHONUTF8: '1',
|
||||||
|
ASSISTANT_NODE: process.execPath,
|
||||||
|
ASSISTANT_LEARNING_HOME: path.join(run, 'learning'),
|
||||||
|
ASSISTANT_ALLOWED_ORIGINS: uiBase,
|
||||||
|
ASSISTANT_BROWSER_HEADLESS: '1',
|
||||||
|
ASSISTANT_BROWSER_CHANNEL: process.env.ASSISTANT_TEST_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : 'chromium'),
|
||||||
|
ASSISTANT_API_TARGET: apiBase,
|
||||||
|
ASSISTANT_INSTANCE_ID: randomUUID(),
|
||||||
|
};
|
||||||
|
const children = new Set();
|
||||||
|
const errors = [];
|
||||||
|
let browser;
|
||||||
|
let page;
|
||||||
|
function launch(command, args, name) {
|
||||||
|
const child = spawn(command, args, { cwd: root, env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
child.assistantGraceful = name.startsWith('server');
|
||||||
|
children.add(child);
|
||||||
|
const log = fs.createWriteStream(path.join(run, name + '.log'));
|
||||||
|
child.stdout.pipe(log); child.stderr.pipe(log);
|
||||||
|
child.on('error', error => errors.push(name + ': ' + error.message));
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
async function stop(child) {
|
||||||
|
await stopOwnedChild(child, { graceful: child?.assistantGraceful, timeout: child?.assistantGraceful ? 20000 : 3000 });
|
||||||
|
children.delete(child);
|
||||||
|
}
|
||||||
|
async function ready(url, child) {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (child.exitCode !== null) throw Error('Service exited: ' + url);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(1500) });
|
||||||
|
if (response.ok && (await response.json()).instance_id === environment.ASSISTANT_INSTANCE_ID) return;
|
||||||
|
} catch {}
|
||||||
|
await delay(250);
|
||||||
|
}
|
||||||
|
throw Error('Service not ready: ' + url);
|
||||||
|
}
|
||||||
|
class Client {
|
||||||
|
cookies = new Map();
|
||||||
|
csrf = '';
|
||||||
|
async request(method, route, body, expected = 200) {
|
||||||
|
const response = await fetch(apiBase + route, {
|
||||||
|
method,
|
||||||
|
headers: { Cookie: [...this.cookies].map(([key, value]) => `${key}=${value}`).join('; '), Origin: apiBase,
|
||||||
|
...(body === undefined ? {} : { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf }) },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(120000),
|
||||||
|
});
|
||||||
|
for (const cookie of response.headers.getSetCookie()) {
|
||||||
|
const pair = cookie.split(';', 1)[0], split = pair.indexOf('=');
|
||||||
|
this.cookies.set(pair.slice(0, split), pair.slice(split + 1));
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
const data = contentType.includes('application/json') ? await response.json() : Buffer.from(await response.arrayBuffer());
|
||||||
|
assert.equal(response.status, expected, `${method} ${route}: ${JSON.stringify(data)}`);
|
||||||
|
if (data?.csrf_token) this.csrf = data.csrf_token;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const username = 'admin';
|
||||||
|
const password = '123456';
|
||||||
|
try {
|
||||||
|
const backendArgs = ['-m', 'scripts.serve', '--port', String(apiPort)];
|
||||||
|
let backend = launch(pythonPath(), backendArgs, 'server');
|
||||||
|
await ready(apiBase + '/api/health', backend);
|
||||||
|
const client = new Client();
|
||||||
|
const initial = await client.request('GET', '/api/auth/status');
|
||||||
|
assert.equal(initial.initialized, true);
|
||||||
|
assert.equal(initial.authenticated, false);
|
||||||
|
await client.request('GET', '/api/learning/tasks', undefined, 401);
|
||||||
|
const setup = await client.request('POST', '/api/auth/login', { username, password });
|
||||||
|
assert.equal(setup.authenticated, true);
|
||||||
|
await client.request('POST', '/api/auth/setup', { username: 'other-maintainer', password: 'OnlySetupFixture-123' }, 409);
|
||||||
|
const empty = await client.request('GET', '/api/learning/tasks');
|
||||||
|
assert.equal(empty.items.length, 0);
|
||||||
|
const demo = await client.request('POST', '/api/learning/demo', {});
|
||||||
|
assert.ok(demo.task_id);
|
||||||
|
const task = await client.request('GET', '/api/learning/tasks/' + demo.task_id);
|
||||||
|
assert.ok(task.observations.length >= 3, 'Demo must save actual observations');
|
||||||
|
assert.ok(task.steps.length >= 4, 'Demo must record search, detail and read steps');
|
||||||
|
assert.ok(task.steps.some(step => step.kind === 'read' && step.output_name === 'comments'));
|
||||||
|
assert.equal(task.candidates.length, 1);
|
||||||
|
const candidate = task.candidates[0];
|
||||||
|
const digest = candidate.digest;
|
||||||
|
const browserRuns = task.validations.filter(record => record.kind === 'browser');
|
||||||
|
assert.equal(browserRuns.length, 2);
|
||||||
|
assert.ok(browserRuns.every(record => record.status === 'passed'));
|
||||||
|
assert.equal(new Set(browserRuns.map(record => JSON.stringify(record.parameters))).size, 2);
|
||||||
|
assert.ok(browserRuns.every(record => JSON.stringify(record.actual_outputs).includes(record.parameters.keyword)));
|
||||||
|
assert.ok(browserRuns.every(record => !JSON.stringify(record.environment).includes(process.platform === 'win32' ? 'macos' : 'windows')));
|
||||||
|
assert.ok(task.observations.some(record => record.screenshot_url));
|
||||||
|
const screenshot = task.observations.find(record => record.screenshot_url).screenshot_url;
|
||||||
|
const image = await client.request('GET', screenshot);
|
||||||
|
assert.equal(image.subarray(1, 4).toString(), 'PNG');
|
||||||
|
const precheck = await client.request('POST', '/api/learning/candidates/' + candidate.id + '/publish-check', {});
|
||||||
|
assert.equal(precheck.ready, false, 'Single OS acceptance must not pretend to cover both OSes');
|
||||||
|
assert.ok(precheck.checks.some(check => !check.passed));
|
||||||
|
// Demo deliberately closes its own browser. A new actual run must use an
|
||||||
|
// explicitly opened and confirmed browser context again.
|
||||||
|
await client.request('POST', '/api/learning/tasks/' + demo.task_id + '/browser/open', {});
|
||||||
|
await client.request('POST', '/api/learning/tasks/' + demo.task_id + '/browser/ready', { identity_label: '本机示例维护者' });
|
||||||
|
await client.request('POST', '/api/learning/candidates/' + candidate.id + '/verify', {
|
||||||
|
parameters: { keyword: '建材' }, expected_outputs: { comments: '肯定不存在的预期内容' }, sample_name: '故意不匹配的预期',
|
||||||
|
});
|
||||||
|
const afterFailure = await client.request('GET', '/api/learning/tasks/' + demo.task_id);
|
||||||
|
assert.equal(afterFailure.candidates[0].digest, digest);
|
||||||
|
assert.ok(afterFailure.validations.some(record => record.kind === 'browser' && record.status === 'failed'));
|
||||||
|
const count = afterFailure.validations.length;
|
||||||
|
await stop(backend);
|
||||||
|
assert.equal(backend.exitCode, 0, 'The owned backend must finish its normal lifespan shutdown');
|
||||||
|
backend = launch(pythonPath(), backendArgs, 'server-restart');
|
||||||
|
await ready(apiBase + '/api/health', backend);
|
||||||
|
const restored = await client.request('GET', '/api/learning/tasks/' + demo.task_id);
|
||||||
|
assert.equal(restored.validations.length, count);
|
||||||
|
assert.equal(restored.candidates[0].digest, digest);
|
||||||
|
|
||||||
|
if (process.argv.includes('--api-only')) {
|
||||||
|
console.log(JSON.stringify({ result: 'passed', output: run, task_id: task.id, browser_validations: browserRuns.length, persisted_validations: count }));
|
||||||
|
process.exitCode = 0;
|
||||||
|
} else {
|
||||||
|
|
||||||
|
const frontend = launch(process.execPath, [path.join(root, 'apps/admin/node_modules/vite/bin/vite.js'), '--config', path.join(root, 'apps/admin/vite.config.ts'), '--host', '127.0.0.1', '--port', String(uiPort), '--strictPort'], 'admin');
|
||||||
|
await ready(uiBase + '/api/health', frontend);
|
||||||
|
browser = await chromium.launch({ headless: true, channel: environment.ASSISTANT_BROWSER_CHANNEL });
|
||||||
|
page = await browser.newPage({ viewport: { width: 1480, height: 1050 } });
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
await page.goto(uiBase);
|
||||||
|
await page.getByLabel(/账号|用户名/).first().fill(username);
|
||||||
|
await page.getByLabel('密码', { exact: true }).fill(password);
|
||||||
|
await page.getByTestId('auth-submit').click();
|
||||||
|
await page.getByRole('heading', { name: '学习批次', exact: true }).waitFor();
|
||||||
|
await page.getByTestId('task-list').waitFor();
|
||||||
|
await page.screenshot({ path: path.join(run, 'tasks.png'), fullPage: true, animations: 'disabled' });
|
||||||
|
await page.getByRole('button', { name: '账本总览', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: '账本总览', exact: true }).waitFor();
|
||||||
|
await page.screenshot({ path: path.join(run, 'overview.png'), fullPage: true, animations: 'disabled' });
|
||||||
|
await page.getByRole('button', { name: '能力实体', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: /查看.*详情/ }).first().click();
|
||||||
|
await page.getByRole('tab', { name: '使用参数', exact: true }).waitFor();
|
||||||
|
await page.getByRole('tab', { name: `测试记录 ${count}`, exact: true }).waitFor();
|
||||||
|
await page.screenshot({ path: path.join(run, 'capability.png'), fullPage: true, animations: 'disabled' });
|
||||||
|
await page.getByRole('tab', { name: '操作链', exact: true }).click();
|
||||||
|
assert.equal(await page.locator('table tbody tr').count(), task.steps.length);
|
||||||
|
await page.getByRole('button', { name: '发布预检', exact: true }).click();
|
||||||
|
await page.getByText('仍有条件需要补齐', { exact: true }).waitFor();
|
||||||
|
await page.getByText('macOS 实际试跑', { exact: true }).waitFor();
|
||||||
|
await page.screenshot({ path: path.join(run, 'preflight.png'), fullPage: true, animations: 'disabled' });
|
||||||
|
await page.getByRole('button', { name: /关闭|close/i }).last().click();
|
||||||
|
await page.getByRole('button', { name: '脚本测试', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: '脚本测试', exact: true }).waitFor();
|
||||||
|
await page.getByTestId('validation-detail').first().waitFor();
|
||||||
|
assert.equal(await page.getByTestId('validation-detail').count(), count);
|
||||||
|
await page.screenshot({ path: path.join(run, 'validations.png'), fullPage: true, animations: 'disabled' });
|
||||||
|
await page.getByTestId('validation-detail').first().click();
|
||||||
|
await page.getByTestId('validation-record').waitFor();
|
||||||
|
await page.screenshot({ path: path.join(run, 'validation-detail.png'), fullPage: true, animations: 'disabled' });
|
||||||
|
await page.getByRole('button', { name: /关闭|close/i }).last().click();
|
||||||
|
await page.getByTestId('nav-tasks').click();
|
||||||
|
await page.getByRole('button', { name: '新建批次', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
assert.equal(await dialog.getByLabel('页面入口', { exact: true }).inputValue(), 'https://www.xiaohongshu.com/explore');
|
||||||
|
await dialog.getByLabel('本地账号引用', { exact: true }).fill('xhs-fixture');
|
||||||
|
await dialog.getByLabel('任务参数', { exact: true }).fill(JSON.stringify({ keyword: '建材' }));
|
||||||
|
await dialog.getByRole('button', { name: '创建任务', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: '小红书搜索与评论读取', exact: true }).waitFor();
|
||||||
|
await page.getByRole('button', { name: /打开浏览器/ }).waitFor();
|
||||||
|
await page.screenshot({ path: path.join(run, 'new-xiaohongshu-task.png'), fullPage: true, animations: 'disabled' });
|
||||||
|
const createdTasks = await client.request('GET', '/api/learning/tasks');
|
||||||
|
assert.ok(createdTasks.items.some(item => item.platform === 'xiaohongshu' && item.account_key === 'xhs-fixture' && item.browser_status === 'closed'));
|
||||||
|
await browser.close(); browser = null;
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
fs.writeFileSync(path.join(run, 'summary.json'), JSON.stringify({ task_id: task.id, candidate_id: candidate.id, digest,
|
||||||
|
observations: task.observations.length, steps: task.steps.length, browser_validations: browserRuns.length,
|
||||||
|
persisted_validations: count, publish_ready: precheck.ready, errors }, null, 2));
|
||||||
|
console.log(JSON.stringify({ result: 'passed', output: run, task_id: task.id, browser_validations: browserRuns.length, persisted_validations: count }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (page && !page.isClosed()) await page.screenshot({ path: path.join(run, 'failure.png'), fullPage: true }).catch(() => {});
|
||||||
|
console.error('Acceptance artifacts: ' + run);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close().catch(() => {});
|
||||||
|
for (const child of [...children]) await stop(child);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { pythonPath, root } from './server.mjs';
|
||||||
|
const child = spawn(pythonPath(), ['-m', 'pytest', 'apps/server/tests', '-q', ...process.argv.slice(2)], {
|
||||||
|
cwd: root, env: { ...process.env, PYTHONUTF8: '1', ASSISTANT_DATABASE_URL: '' }, stdio: 'inherit', windowsHide: true,
|
||||||
|
});
|
||||||
|
child.on('exit', code => { process.exitCode = code || 0; });
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# 本机学习浏览器工作进程
|
||||||
|
|
||||||
|
维护后台通过标准输入输出的逐行 JSON 调用此 Node.js 进程。浏览器、登录资料和截图在维护者本机,工作进程不会调用模型或存储平台密码。
|
||||||
|
|
||||||
|
输入为 `{ "id": "request-id", "method": "capture", "params": { "task_id": "task-id" } }`。输出为相同 `id` 的 `result`,或包含 `message`、`code` 的 `error`。标准输出只用于协议,进程按顺序处理请求。
|
||||||
|
|
||||||
|
关闭标准输入或标准输出管道断开会立即关闭浏览器上下文,使执行中的试跑自然中断;所有关闭入口等待同一个清理过程,不会重放动作。调用方应等待工作进程实际退出,再重开同一资料目录。
|
||||||
|
|
||||||
|
## 方法
|
||||||
|
|
||||||
|
- `open`:传入 `task_id`、`account_key`、`entry_url`,打开固定账号资料目录,进入等待人工登录与确认阶段。
|
||||||
|
- `ready`:传入 `identity_label`,明确记录维护者人工确认。首版没有自动平台身份核验器,不能把该记录解释为自动认证成功。
|
||||||
|
- `capture`:采集已确认页面的文本、候选定位和视口截图,返回 `capture_id` 与证据路径。
|
||||||
|
- `action`:执行最近一次采集中的 `fill`、`click`、`read`。定位重新核验;普通动作要求唯一目标。
|
||||||
|
- `verify`:从入口运行已生成的固定步骤,用参数替换填写值,核对指定输出文本并保存实际页面证据。
|
||||||
|
- `close`:关闭会话、释放账号占用,保留资料目录。
|
||||||
|
- `status`:查询当前会话是 `awaiting_user`、`observing` 还是 `closed`。
|
||||||
|
|
||||||
|
## 配置与边界
|
||||||
|
|
||||||
|
- `ASSISTANT_LEARNING_HOME`:学习资料根目录,默认 `.runtime/learning`。账号目录使用入口 origin 与 `account_key` 的固定 SHA-256 标识;同账号不能被不同活跃任务同时使用。
|
||||||
|
- `ASSISTANT_BROWSER_CHANNEL`:浏览器通道,默认 `chromium`。开发机可指定已安装的 `msedge`;无浏览器时返回明确错误,不自动下载安装。
|
||||||
|
- `ASSISTANT_BROWSER_HEADLESS=1`:仅供隔离验收使用。正常维护登录使用有头浏览器。
|
||||||
|
- 首版限制在入口的同一 origin,尚不支持跨域 SSO 跳转;需扩展站点范围时先补明确的范围契约。
|
||||||
|
- 首版操作只涵盖普通文本填写、同站点链接、明确的搜索与展开按钮及读取;关注、私信、发送、提交、支付、删除不在运行范围内。自动步骤期间拦截非 GET/HEAD/OPTIONS 请求。
|
||||||
|
- 人工确认前不采集页面;采集跳过敏感表单和输入值,截图遮盖输入区。手机号、验证码、密码不得作为步骤参数或人工确认标签。
|
||||||
|
- 验证至少需要一个非空输出预期。结果中 `os`、浏览器版本和时间由实际执行进程生成,失败不会被记录成通过。
|
||||||
|
|
||||||
|
## 本机隔离回归
|
||||||
|
|
||||||
|
安装项目依赖及相应浏览器后运行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
node tools/learning/test-browser-worker.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 默认使用已安装的 Edge,其他系统默认使用 Playwright Chromium,可通过 `ASSISTANT_BROWSER_CHANNEL` 覆盖。脚本启动仅绑定回环地址的临时页面,以独立子进程通过真实 JSONL 协议调用工作进程,不访问内容平台、模型或商家数据库。
|
||||||
|
|
||||||
|
测试使用工作区 `.tmp/worker-regression-*` 下的新资料目录,覆盖人工确认门禁、账号互斥、敏感字段过滤、发送阻止、参数化实际读回、错误及空预期、关闭重开 Cookie 复用、协议错误恢复、关闭状态,以及试跑中收到 EOF 后及时退出并由新进程重开原资料目录。成功后清理本轮目录;失败时保留证据。加 `--keep-artifacts` 可保留成功证据和 `report.json`。
|
||||||
|
|
||||||
|
界面中,“固定规则校验”可独立执行;“浏览器试跑”要求该学习任务已打开会话并由维护者人工确认当前账号。关闭后重新打开仍要确认,不能把持久化 Cookie 或上次确认视为当前账号自动核验结果。
|
||||||
|
|
@ -0,0 +1,600 @@
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const HOME = path.resolve(process.env.ASSISTANT_LEARNING_HOME || '.runtime/learning');
|
||||||
|
const CHANNEL = process.env.ASSISTANT_BROWSER_CHANNEL || 'chromium';
|
||||||
|
const HEADLESS = process.env.ASSISTANT_BROWSER_HEADLESS === '1';
|
||||||
|
const TIMEOUT = 8_000;
|
||||||
|
const MAX_STEPS = 30;
|
||||||
|
const sessions = new Map();
|
||||||
|
const contexts = new Set();
|
||||||
|
const accountOwners = new Map();
|
||||||
|
const closingAccounts = new Map();
|
||||||
|
let chromiumPromise;
|
||||||
|
let shuttingDown = false;
|
||||||
|
let shutdownPromise;
|
||||||
|
|
||||||
|
export class WorkerError extends Error {
|
||||||
|
constructor(message, code = 'WORKER_ERROR') {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(value, name, max = 2_000) {
|
||||||
|
if (typeof value !== 'string' || !value.trim() || value.length > max) {
|
||||||
|
throw new WorkerError(`${name} 必须是非空文本,最多 ${max} 字符`, 'INVALID_INPUT');
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpUrl(value) {
|
||||||
|
let url;
|
||||||
|
try { url = new URL(requireString(value, 'entry_url', 4_096)); }
|
||||||
|
catch { throw new WorkerError('仅支持明确的 http 或 https 页面地址', 'INVALID_URL'); }
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||||
|
throw new WorkerError('仅支持不含账号密码的 http 或 https 地址', 'INVALID_URL');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
||||||
|
const redactText = value => String(value || '')
|
||||||
|
.replace(/(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/g, '[手机号已隐藏]')
|
||||||
|
.replace(/((?:验证码|校验码|动态码|短信码|password|verification\s*code)\s*[::]?\s*)[\w-]{4,12}/gi, '$1[已隐藏]');
|
||||||
|
|
||||||
|
function redactUrl(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
for (const key of [...url.searchParams.keys()]) {
|
||||||
|
if (/password|passwd|phone|mobile|captcha|verification|(?:^|_)code$|token|secret|session|credential/i.test(key)) {
|
||||||
|
url.searchParams.set(key, '[已隐藏]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/token|password|secret|session|credential/i.test(url.hash)) url.hash = '[已隐藏]';
|
||||||
|
return redactText(url.href);
|
||||||
|
} catch { return redactText(value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chromium() {
|
||||||
|
if (!chromiumPromise) chromiumPromise = import('playwright').then(module => module.chromium).catch(() => {
|
||||||
|
throw new WorkerError('缺少 Playwright 依赖,请先在项目中安装依赖', 'MISSING_DEPENDENCY');
|
||||||
|
});
|
||||||
|
return chromiumPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionFor(taskId, requireReady = true) {
|
||||||
|
const key = requireString(taskId, 'task_id', 128);
|
||||||
|
const session = sessions.get(key);
|
||||||
|
if (!session || session.closed || session.page.isClosed()) {
|
||||||
|
throw new WorkerError('浏览器会话已关闭,请重新打开并人工确认当前页面', 'SESSION_CLOSED');
|
||||||
|
}
|
||||||
|
if (session.blockedNavigation) {
|
||||||
|
throw new WorkerError('页面尝试离开本次学习的站点范围,请关闭会话后检查入口', 'SCOPE_BLOCKED');
|
||||||
|
}
|
||||||
|
if (requireReady && !session.ready) {
|
||||||
|
throw new WorkerError('请先在官方页面完成登录,再明确开始观察;登录阶段不采集证据', 'AWAITING_USER');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOrigin(session, url) {
|
||||||
|
let parsed;
|
||||||
|
try { parsed = new URL(url); } catch { throw new WorkerError('当前页面地址不可核验', 'SCOPE_BLOCKED'); }
|
||||||
|
if (parsed.origin !== session.origin) {
|
||||||
|
throw new WorkerError('当前页面不属于本次学习允许的站点', 'SCOPE_BLOCKED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle(page) {
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 3_000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(180);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(params) {
|
||||||
|
const taskId = requireString(params.task_id, 'task_id', 128);
|
||||||
|
const accountKey = requireString(params.account_key, 'account_key', 256);
|
||||||
|
const entry = httpUrl(params.entry_url);
|
||||||
|
const existing = sessions.get(taskId);
|
||||||
|
if (existing && !existing.closed && !existing.page.isClosed()) {
|
||||||
|
if (existing.accountKey !== accountKey || existing.origin !== entry.origin) {
|
||||||
|
throw new WorkerError('当前任务已经持有不同的账号或站点会话,请先关闭', 'SESSION_CONFLICT');
|
||||||
|
}
|
||||||
|
return sessionInfo(existing);
|
||||||
|
}
|
||||||
|
const accountId = digest(`${entry.origin}\0${accountKey}`);
|
||||||
|
if (closingAccounts.has(accountId)) await closingAccounts.get(accountId);
|
||||||
|
const owner = accountOwners.get(accountId);
|
||||||
|
if (owner && owner !== taskId) {
|
||||||
|
throw new WorkerError('同一平台账号正在被另一学习任务使用,请先关闭原会话', 'ACCOUNT_BUSY');
|
||||||
|
}
|
||||||
|
const profile = path.join(HOME, 'profiles', accountId);
|
||||||
|
await fs.mkdir(profile, { recursive: true });
|
||||||
|
accountOwners.set(accountId, taskId);
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
const browserType = await chromium();
|
||||||
|
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
|
||||||
|
context = await browserType.launchPersistentContext(profile, {
|
||||||
|
channel: CHANNEL,
|
||||||
|
headless: HEADLESS,
|
||||||
|
viewport: { width: 1280, height: 840 },
|
||||||
|
acceptDownloads: false,
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
contexts.add(context);
|
||||||
|
context.once('close', () => contexts.delete(context));
|
||||||
|
// An EOF may arrive while launching. Never expose a newly launched context
|
||||||
|
// after shutdown has already taken its snapshot of active contexts.
|
||||||
|
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
|
||||||
|
const page = context.pages()[0] || await context.newPage();
|
||||||
|
await Promise.allSettled(context.pages().filter(other => other !== page).map(other => other.close()));
|
||||||
|
page.setDefaultTimeout(TIMEOUT);
|
||||||
|
const session = {
|
||||||
|
taskId, accountKey, accountId, context, page, origin: entry.origin,
|
||||||
|
entryUrl: entry.href, sessionId: randomUUID(), ready: false, closed: false,
|
||||||
|
identityLabel: null, lastCapture: null, blockedNavigation: null,
|
||||||
|
automating: false, blockedWrites: 0,
|
||||||
|
};
|
||||||
|
await context.route('**/*', async route => {
|
||||||
|
const request = route.request();
|
||||||
|
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
|
||||||
|
let target;
|
||||||
|
try { target = new URL(request.url()); } catch { target = null; }
|
||||||
|
if (!target || target.origin !== session.origin) {
|
||||||
|
session.blockedNavigation = true;
|
||||||
|
await route.abort('blockedbyclient');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Automation in this first read-only learning slice never sends write requests.
|
||||||
|
// Human login happens before ready and does not use this automation flag.
|
||||||
|
if (session.automating && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
|
||||||
|
session.blockedWrites += 1;
|
||||||
|
await route.abort('blockedbyclient');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
context.on('page', popup => {
|
||||||
|
if (popup !== page) void popup.close().catch(() => {});
|
||||||
|
});
|
||||||
|
const markClosed = () => {
|
||||||
|
session.closed = true;
|
||||||
|
session.ready = false;
|
||||||
|
session.lastCapture = null;
|
||||||
|
if (accountOwners.get(accountId) === taskId) accountOwners.delete(accountId);
|
||||||
|
};
|
||||||
|
context.on('close', markClosed);
|
||||||
|
page.on('close', () => {
|
||||||
|
session.closed = true;
|
||||||
|
session.ready = false;
|
||||||
|
session.lastCapture = null;
|
||||||
|
void closeContext(session);
|
||||||
|
});
|
||||||
|
sessions.set(taskId, session);
|
||||||
|
await page.goto(entry.href, { waitUntil: 'domcontentloaded', timeout: 25_000 });
|
||||||
|
requireOrigin(session, page.url());
|
||||||
|
return sessionInfo(session);
|
||||||
|
} catch (error) {
|
||||||
|
if (context) await context.close().catch(() => {});
|
||||||
|
sessions.delete(taskId);
|
||||||
|
if (accountOwners.get(accountId) === taskId) accountOwners.delete(accountId);
|
||||||
|
if (/executable|browser.*not found|distribution.*not found/i.test(error.message)) {
|
||||||
|
throw new WorkerError(`浏览器不可用。请安装 Playwright Chromium,或配置本机已安装的浏览器通道。当前通道 ${CHANNEL}`, 'BROWSER_UNAVAILABLE');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionInfo(session) {
|
||||||
|
return {
|
||||||
|
session_id: session.sessionId,
|
||||||
|
status: session.closed ? 'closed' : session.ready ? 'observing' : 'awaiting_user',
|
||||||
|
url: redactUrl(session.page.url()),
|
||||||
|
manual_confirmation: session.ready,
|
||||||
|
identity_label: session.identityLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ready(params) {
|
||||||
|
const session = sessionFor(params.task_id, false);
|
||||||
|
requireOrigin(session, session.page.url());
|
||||||
|
const label = requireString(params.identity_label, 'identity_label', 100);
|
||||||
|
if (/(?:\+?86[-\s]?)?1[3-9]\d{9}|验证码|password|密码/i.test(label)) {
|
||||||
|
throw new WorkerError('确认标签请使用测试账号昵称或代号,不填写手机号、密码或验证码', 'SENSITIVE_INPUT');
|
||||||
|
}
|
||||||
|
session.ready = true;
|
||||||
|
session.identityLabel = label;
|
||||||
|
session.lastCapture = null;
|
||||||
|
return { ...sessionInfo(session), identity_verification: 'operator_confirmed',
|
||||||
|
note: '此记录是维护者人工确认,不代表自动完成平台账号身份核验' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describePage(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const PRIVATE = /password|passwd|one.time.code|verification|captcha|phone|mobile|(?:^|[-_])tel(?:$|[-_])|sms|验证码|校验码|动态码|短信码|手机号|手机号码|密码|口令|登录|登陆|sign.?in|log.?in/i;
|
||||||
|
const phone = /(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/g;
|
||||||
|
const clean = text => String(text || '').replace(phone, '[手机号已隐藏]').trim();
|
||||||
|
const fieldLabel = element => {
|
||||||
|
const id = element.id;
|
||||||
|
const explicit = id ? document.querySelector(`label[for="${CSS.escape(id)}"]`)?.textContent : '';
|
||||||
|
return [element.getAttribute('aria-label'), element.getAttribute('placeholder'), explicit,
|
||||||
|
element.getAttribute('title'), element.getAttribute('name'), element.getAttribute('autocomplete')]
|
||||||
|
.filter(Boolean).join(' ').trim();
|
||||||
|
};
|
||||||
|
const isSensitive = element => {
|
||||||
|
if (element.matches('input[type=password],input[type=tel],[autocomplete="one-time-code"]')) return true;
|
||||||
|
if (PRIVATE.test(fieldLabel(element))) return true;
|
||||||
|
const form = element.closest('form,[role=dialog]');
|
||||||
|
return Boolean(form && (form.querySelector('input[type=password],input[type=tel],[autocomplete="one-time-code"]') ||
|
||||||
|
PRIVATE.test([form.getAttribute('id'), form.getAttribute('class'), form.getAttribute('aria-label'), form.textContent?.slice(0, 1200)].join(' '))));
|
||||||
|
};
|
||||||
|
const visible = element => {
|
||||||
|
const style = getComputedStyle(element), rect = element.getBoundingClientRect();
|
||||||
|
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
||||||
|
};
|
||||||
|
const selectorFor = element => {
|
||||||
|
if (element.getAttribute('data-testid')) return `[data-testid=${JSON.stringify(element.getAttribute('data-testid'))}]`;
|
||||||
|
if (element.id) return `#${CSS.escape(element.id)}`;
|
||||||
|
if (element.getAttribute('data-learning-read')) return `[data-learning-read=${JSON.stringify(element.getAttribute('data-learning-read'))}]`;
|
||||||
|
const segments = [];
|
||||||
|
let current = element;
|
||||||
|
for (let level = 0; current && current !== document.body && level < 7; level += 1, current = current.parentElement) {
|
||||||
|
if (current.id) { segments.unshift(`#${CSS.escape(current.id)}`); break; }
|
||||||
|
const tag = current.tagName.toLowerCase();
|
||||||
|
const siblings = current.parentElement ? Array.from(current.parentElement.children).filter(node => node.tagName === current.tagName) : [];
|
||||||
|
segments.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag);
|
||||||
|
}
|
||||||
|
return segments.join(' > ');
|
||||||
|
};
|
||||||
|
const stripPrivate = root => {
|
||||||
|
root.querySelectorAll('script,style,noscript,iframe,input,textarea,select,[contenteditable=true]').forEach(node => node.remove());
|
||||||
|
root.querySelectorAll('form,[role=dialog]').forEach(node => {
|
||||||
|
if (PRIVATE.test([node.getAttribute('id'), node.getAttribute('class'), node.getAttribute('aria-label'), node.textContent?.slice(0, 1200)].join(' '))) node.remove();
|
||||||
|
});
|
||||||
|
return clean(root.textContent).replace(/\s+/g, ' ');
|
||||||
|
};
|
||||||
|
const clone = document.body.cloneNode(true);
|
||||||
|
const text = stripPrivate(clone).slice(0, 16_000);
|
||||||
|
const elements = [], seen = new Set(), masks = new Set();
|
||||||
|
for (const element of document.querySelectorAll('body *')) {
|
||||||
|
if (element.children.length === 0 && /(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/.test(element.textContent || '')) {
|
||||||
|
masks.add(selectorFor(element));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const candidates = document.querySelectorAll('input,textarea,button,a,[data-testid],[data-learning-read],main,article,[role=main],[role=button]');
|
||||||
|
for (const element of candidates) {
|
||||||
|
if (!visible(element)) continue;
|
||||||
|
const selector = selectorFor(element);
|
||||||
|
if (!selector) continue;
|
||||||
|
if (isSensitive(element)) {
|
||||||
|
const form = element.closest('form,[role=dialog]');
|
||||||
|
masks.add(selectorFor(form || element));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(selector) || elements.length >= 120) continue;
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
const kind = ['input', 'textarea'].includes(tag) ? 'fill' : (tag === 'button' || tag === 'a' || element.getAttribute('role') === 'button') ? 'click' : 'read';
|
||||||
|
if (kind === 'fill' && !['text','search','url',''].includes((element.getAttribute('type') || '').toLowerCase())) continue;
|
||||||
|
if (element.hasAttribute('disabled')) continue;
|
||||||
|
let label = kind === 'fill' ? fieldLabel(element) : stripPrivate(element.cloneNode(true));
|
||||||
|
label = clean(label || element.getAttribute('aria-label') || tag).slice(0, 180);
|
||||||
|
if (!label) label = tag;
|
||||||
|
let count = 0;
|
||||||
|
try { count = document.querySelectorAll(selector).length; } catch { continue; }
|
||||||
|
seen.add(selector);
|
||||||
|
elements.push({ selector, tag, label, kind, unique: count === 1, count, sensitive: false });
|
||||||
|
}
|
||||||
|
return { title: clean(document.title).slice(0, 250), text, elements, masks: [...masks].filter(Boolean) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureSession(session, note = '') {
|
||||||
|
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
|
||||||
|
requireOrigin(session, session.page.url());
|
||||||
|
const description = await describePage(session.page);
|
||||||
|
const captureId = randomUUID();
|
||||||
|
const directory = path.join(HOME, 'evidence', digest(session.taskId).slice(0, 24));
|
||||||
|
await fs.mkdir(directory, { recursive: true });
|
||||||
|
const screenshotPath = path.join(directory, `${captureId}.png`);
|
||||||
|
const mask = description.masks.map(selector => session.page.locator(selector));
|
||||||
|
mask.push(session.page.locator('input,textarea,[contenteditable=true]'));
|
||||||
|
await session.page.screenshot({ path: screenshotPath, fullPage: false, mask, maskColor: '#1d252b', timeout: 10_000 });
|
||||||
|
const snapshot = {
|
||||||
|
capture_id: captureId,
|
||||||
|
url: redactUrl(session.page.url()),
|
||||||
|
title: redactText(description.title),
|
||||||
|
text: redactText(description.text),
|
||||||
|
elements: description.elements,
|
||||||
|
screenshot_path: screenshotPath,
|
||||||
|
relative_screenshot_path: path.relative(HOME, screenshotPath).split(path.sep).join('/'),
|
||||||
|
captured_at: new Date().toISOString(),
|
||||||
|
note: redactText(note).slice(0, 1000),
|
||||||
|
identity_verification: 'operator_confirmed',
|
||||||
|
};
|
||||||
|
session.lastCapture = snapshot;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture(params) {
|
||||||
|
const session = sessionFor(params.task_id);
|
||||||
|
return captureSession(session, params.note || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const DANGEROUS = /关注|私信|发送|提交|支付|购买|删除|移除|注销|退出登录|确认订单|下单|转账|follow|unfollow|send|submit|purchase|delete|remove|logout|sign.out|pay(?:ment)?/i;
|
||||||
|
const READ_ONLY_BUTTON = /搜索|查询|检索|筛选|展开|收起|更多|下一页|上一页|刷新|加载|search|filter|expand|collapse|more|next|previous|refresh|load/i;
|
||||||
|
|
||||||
|
async function actionSession(session, params, { captureBefore = true } = {}) {
|
||||||
|
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
|
||||||
|
const kind = params.kind;
|
||||||
|
if (!['fill', 'click', 'read'].includes(kind)) throw new WorkerError('首版只支持 fill、click、read 三类步骤', 'UNSUPPORTED_ACTION');
|
||||||
|
const selector = requireString(params.selector, 'selector', 1_000);
|
||||||
|
const previous = session.lastCapture;
|
||||||
|
if (!previous || previous.url !== redactUrl(session.page.url())) {
|
||||||
|
throw new WorkerError('页面已变化或尚未采集,请先重新采集当前页面', 'FRESH_CAPTURE_REQUIRED');
|
||||||
|
}
|
||||||
|
const observed = previous.elements.find(element => element.selector === selector);
|
||||||
|
if (!observed || observed.sensitive) throw new WorkerError('定位必须来自最近一次页面采集的可用元素', 'UNOBSERVED_SELECTOR');
|
||||||
|
if (kind === 'fill' && observed.kind !== 'fill') throw new WorkerError('该元素不是可填写的普通输入框', 'ACTION_MISMATCH');
|
||||||
|
if (kind === 'click' && observed.kind !== 'click') throw new WorkerError('该元素不是已观察到的链接或按钮', 'ACTION_MISMATCH');
|
||||||
|
const locator = session.page.locator(selector);
|
||||||
|
const count = await locator.count();
|
||||||
|
if (!count || (kind !== 'read' && count !== 1)) throw new WorkerError(`定位当前匹配 ${count} 个元素,操作要求唯一目标`, 'NON_UNIQUE_TARGET');
|
||||||
|
if (kind === 'read' && count > 200) throw new WorkerError('单次读取最多 200 个对象,请缩小定位范围', 'READ_LIMIT');
|
||||||
|
const current = await describePage(session.page);
|
||||||
|
const currentObserved = current.elements.find(element => element.selector === selector);
|
||||||
|
if (!currentObserved || (kind !== 'read' && (currentObserved.label !== observed.label || currentObserved.tag !== observed.tag))) {
|
||||||
|
throw new WorkerError('目标语义已变化,请重新采集后确认', 'STALE_TARGET');
|
||||||
|
}
|
||||||
|
const before = captureBefore ? await captureSession(session) : previous;
|
||||||
|
session.automating = true;
|
||||||
|
const initialBlockedWrites = session.blockedWrites;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
if (kind === 'fill') {
|
||||||
|
if (typeof params.value !== 'string' || params.value.length > 2_000) throw new WorkerError('填写值必须是最多 2000 字符的文本', 'INVALID_INPUT');
|
||||||
|
if (/(?:\+?86[-\s]?)?1[3-9]\d{9}|(?:验证码|密码|password)\s*[::]/i.test(params.value)) {
|
||||||
|
throw new WorkerError('此学习步骤不接受平台手机号、密码或验证码', 'SENSITIVE_INPUT');
|
||||||
|
}
|
||||||
|
await locator.fill(params.value, { timeout: TIMEOUT });
|
||||||
|
result = { filled: true, selector };
|
||||||
|
} else if (kind === 'click') {
|
||||||
|
const target = await locator.evaluate(element => ({
|
||||||
|
tag: element.tagName.toLowerCase(),
|
||||||
|
label: [element.textContent, element.getAttribute('aria-label'), element.getAttribute('title')].filter(Boolean).join(' ').trim(),
|
||||||
|
href: element instanceof HTMLAnchorElement ? element.href : '',
|
||||||
|
formMethod: element.closest('form')?.getAttribute('method') || 'get',
|
||||||
|
}));
|
||||||
|
if (DANGEROUS.test(target.label) || DANGEROUS.test(target.href)) {
|
||||||
|
throw new WorkerError('首版学习验证不执行关注、私信、发送、提交、支付或删除动作', 'WRITE_ACTION_BLOCKED');
|
||||||
|
}
|
||||||
|
if (target.tag === 'a' && target.href) {
|
||||||
|
const url = httpUrl(target.href);
|
||||||
|
requireOrigin(session, url.href);
|
||||||
|
} else if (!READ_ONLY_BUTTON.test(target.label) || target.formMethod.toLowerCase() !== 'get') {
|
||||||
|
throw new WorkerError('首版仅允许同站点链接或明确的搜索、查询、展开、加载按钮', 'WRITE_ACTION_BLOCKED');
|
||||||
|
}
|
||||||
|
await locator.click({ timeout: TIMEOUT });
|
||||||
|
await settle(session.page);
|
||||||
|
requireOrigin(session, session.page.url());
|
||||||
|
result = { clicked: true, selector, url: redactUrl(session.page.url()) };
|
||||||
|
} else {
|
||||||
|
result = await locator.evaluateAll(elements => {
|
||||||
|
const sensitive = /password|passwd|one.time.code|verification|captcha|phone|mobile|sms|验证码|校验码|动态码|短信码|手机号|手机号码|密码|口令|登录|登陆|sign.?in|log.?in/i;
|
||||||
|
return elements.map(element => {
|
||||||
|
const clone = element.cloneNode(true);
|
||||||
|
clone.querySelectorAll('script,style,noscript,iframe,input,textarea,select,[contenteditable=true]').forEach(node => node.remove());
|
||||||
|
clone.querySelectorAll('form,[role=dialog]').forEach(node => {
|
||||||
|
if (sensitive.test([node.id, node.className, node.getAttribute('aria-label'), node.textContent?.slice(0, 1200)].join(' '))) node.remove();
|
||||||
|
});
|
||||||
|
const text = (clone.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 4_000)
|
||||||
|
.replace(/(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/g, '[手机号已隐藏]');
|
||||||
|
let href;
|
||||||
|
if (element instanceof HTMLAnchorElement && ['http:', 'https:'].includes(new URL(element.href).protocol)) href = element.href;
|
||||||
|
return href ? { text, href } : { text };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
result = result.map(item => ({ ...item, text: redactText(item.text), ...(item.href ? { href: redactUrl(item.href) } : {}) }));
|
||||||
|
}
|
||||||
|
await session.page.waitForTimeout(120);
|
||||||
|
if (session.blockedWrites > initialBlockedWrites) throw new WorkerError('页面尝试产生写请求,已阻止并停止本次只读步骤', 'WRITE_REQUEST_BLOCKED');
|
||||||
|
} finally {
|
||||||
|
session.automating = false;
|
||||||
|
}
|
||||||
|
const after = await captureSession(session);
|
||||||
|
return { result, before, after };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function action(params) {
|
||||||
|
return actionSession(sessionFor(params.task_id), params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close(params) {
|
||||||
|
const taskId = requireString(params.task_id, 'task_id', 128);
|
||||||
|
const session = sessions.get(taskId);
|
||||||
|
if (!session) return { closed: true, profile_preserved: true };
|
||||||
|
session.closed = true;
|
||||||
|
session.ready = false;
|
||||||
|
session.lastCapture = null;
|
||||||
|
await closeContext(session);
|
||||||
|
sessions.delete(taskId);
|
||||||
|
if (accountOwners.get(session.accountId) === taskId) accountOwners.delete(session.accountId);
|
||||||
|
return { closed: true, profile_preserved: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeContext(session) {
|
||||||
|
if (closingAccounts.has(session.accountId)) return closingAccounts.get(session.accountId);
|
||||||
|
const promise = session.context.close().catch(() => {}).finally(() => {
|
||||||
|
if (accountOwners.get(session.accountId) === session.taskId) accountOwners.delete(session.accountId);
|
||||||
|
closingAccounts.delete(session.accountId);
|
||||||
|
});
|
||||||
|
closingAccounts.set(session.accountId, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExpected(expected, outputs) {
|
||||||
|
if (!expected || typeof expected !== 'object' || Array.isArray(expected) || !Object.keys(expected).length) {
|
||||||
|
throw new WorkerError('实际试跑必须提供至少一个明确的输出预期', 'EXPECTED_OUTPUT_REQUIRED');
|
||||||
|
}
|
||||||
|
const errors = [];
|
||||||
|
for (const [name, value] of Object.entries(expected)) {
|
||||||
|
const samples = Array.isArray(value) ? value : [value];
|
||||||
|
if (!samples.length || samples.some(item => typeof item !== 'string' || !item.trim())) {
|
||||||
|
errors.push(`输出 ${name} 的预期必须是非空文本或非空文本列表`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Object.hasOwn(outputs, name)) {
|
||||||
|
errors.push(`没有读回预期输出 ${name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const actual = Array.isArray(outputs[name]) ? outputs[name].map(item => item.text || '').join('\n') : '';
|
||||||
|
if (!actual.trim()) { errors.push(`输出 ${name} 的实际读取结果为空`); continue; }
|
||||||
|
for (const sample of samples) if (!actual.includes(sample)) errors.push(`输出 ${name} 未包含预期内容 ${redactText(sample).slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify(params) {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const session = sessionFor(params.task_id);
|
||||||
|
const outputs = Object.create(null), evidence = [], errors = [];
|
||||||
|
const expected = params.expected_outputs;
|
||||||
|
// Validate assertions before any browser movement.
|
||||||
|
assertExpected(expected, {});
|
||||||
|
for (const value of Object.values(expected)) {
|
||||||
|
const samples = Array.isArray(value) ? value : [value];
|
||||||
|
if (!samples.length || samples.some(item => typeof item !== 'string' || !item.trim())) {
|
||||||
|
throw new WorkerError('每个输出预期必须是非空文本或非空文本列表', 'INVALID_EXPECTED_OUTPUT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(params.steps) || !params.steps.length || params.steps.length > MAX_STEPS) {
|
||||||
|
throw new WorkerError(`试跑要求 1–${MAX_STEPS} 个固定步骤`, 'INVALID_STEPS');
|
||||||
|
}
|
||||||
|
const entry = httpUrl(params.entry_url);
|
||||||
|
requireOrigin(session, entry.href);
|
||||||
|
const parameters = params.parameters && typeof params.parameters === 'object' ? params.parameters : {};
|
||||||
|
const deadline = Date.now() + 90_000;
|
||||||
|
try {
|
||||||
|
await session.page.goto(entry.href, { waitUntil: 'domcontentloaded', timeout: 20_000 });
|
||||||
|
await settle(session.page);
|
||||||
|
evidence.push(await captureSession(session, '变参试跑入口'));
|
||||||
|
for (let index = 0; index < params.steps.length; index += 1) {
|
||||||
|
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
|
||||||
|
if (Date.now() > deadline) throw new WorkerError('试跑超过本轮时间预算', 'VERIFY_BUDGET');
|
||||||
|
const step = params.steps[index];
|
||||||
|
if (!step || typeof step !== 'object' || !['fill', 'click', 'read'].includes(step.kind)) throw new WorkerError(`第 ${index + 1} 步类型无效`, 'INVALID_STEPS');
|
||||||
|
const actionParams = { ...step };
|
||||||
|
if (step.kind === 'fill' && step.parameter) {
|
||||||
|
if (!Object.hasOwn(parameters, step.parameter)) throw new WorkerError(`缺少运行参数 ${step.parameter}`, 'MISSING_PARAMETER');
|
||||||
|
actionParams.value = String(parameters[step.parameter]);
|
||||||
|
}
|
||||||
|
if (step.kind === 'read') requireString(step.output_name, 'output_name', 80);
|
||||||
|
const result = await actionSession(session, actionParams, { captureBefore: false });
|
||||||
|
evidence.push(result.after);
|
||||||
|
if (step.kind === 'read') outputs[step.output_name] = result.result;
|
||||||
|
}
|
||||||
|
errors.push(...assertExpected(expected, outputs));
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(redactText(error.message));
|
||||||
|
if (!session.closed && !session.page.isClosed()) {
|
||||||
|
await captureSession(session, '失败后页面状态').then(snapshot => evidence.push(snapshot)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: errors.length === 0,
|
||||||
|
outputs, errors, evidence,
|
||||||
|
os: process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : process.platform,
|
||||||
|
browser_version: session.context.browser()?.version() || 'unknown',
|
||||||
|
identity_verification: 'operator_confirmed',
|
||||||
|
started_at: startedAt, finished_at: new Date().toISOString(),
|
||||||
|
model_calls: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const methods = { open, ready, capture, action, close, verify,
|
||||||
|
status: async params => {
|
||||||
|
const session = sessions.get(requireString(params.task_id, 'task_id', 128));
|
||||||
|
return session && !session.closed ? sessionInfo(session) : { status: 'closed' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function dispatch(method, params = {}) {
|
||||||
|
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
|
||||||
|
if (!Object.hasOwn(methods, method)) throw new WorkerError('不支持的工作进程方法', 'UNKNOWN_METHOD');
|
||||||
|
return methods[method](params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
if (shutdownPromise) return shutdownPromise;
|
||||||
|
shuttingDown = true;
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
session.closed = true;
|
||||||
|
session.ready = false;
|
||||||
|
session.lastCapture = null;
|
||||||
|
}
|
||||||
|
shutdownPromise = (async () => {
|
||||||
|
// Closing the context interrupts a pending goto/click/capture immediately;
|
||||||
|
// it must not wait for the serial RPC loop to finish the current request.
|
||||||
|
await Promise.allSettled([...contexts].map(context => context.close()));
|
||||||
|
await Promise.allSettled([...closingAccounts.values()]);
|
||||||
|
sessions.clear();
|
||||||
|
accountOwners.clear();
|
||||||
|
})();
|
||||||
|
return shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(HOME, { recursive: true });
|
||||||
|
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||||
|
const stop = () => {
|
||||||
|
input.close();
|
||||||
|
process.stdin.pause();
|
||||||
|
void shutdown();
|
||||||
|
};
|
||||||
|
input.once('close', stop);
|
||||||
|
process.stdin.once('error', stop);
|
||||||
|
// A terminated parent may close its read pipe before closing our stdin.
|
||||||
|
// Treat EPIPE as loss of the owner, never an unhandled stream exception.
|
||||||
|
process.stdout.on('error', error => {
|
||||||
|
if (error.code !== 'EPIPE') process.exitCode = 1;
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
process.on('SIGINT', stop);
|
||||||
|
process.on('SIGTERM', stop);
|
||||||
|
const reply = value => {
|
||||||
|
if (!shuttingDown && !process.stdout.destroyed) process.stdout.write(JSON.stringify(value) + '\n');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
for await (const line of input) {
|
||||||
|
if (shuttingDown) break;
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let request;
|
||||||
|
try {
|
||||||
|
if (line.length > 1_000_000) throw new WorkerError('请求超过大小限制', 'REQUEST_TOO_LARGE');
|
||||||
|
request = JSON.parse(line);
|
||||||
|
if (!request || typeof request !== 'object' || request.id === undefined) throw new WorkerError('请求缺少 id', 'INVALID_REQUEST');
|
||||||
|
const result = await dispatch(request.method, request.params || {});
|
||||||
|
reply({ id: request.id, result });
|
||||||
|
} catch (error) {
|
||||||
|
const code = error.code || 'BROWSER_ERROR';
|
||||||
|
reply({ id: request?.id ?? null, error: { message: redactText(error.message).slice(0, 1_200), code } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
input.close();
|
||||||
|
process.stdin.pause();
|
||||||
|
await shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||||
|
main().catch(error => {
|
||||||
|
process.stderr.write(`学习工作进程启动失败:${redactText(error.message)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import readline from 'node:readline';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const TEMP_ROOT = path.join(ROOT, '.tmp');
|
||||||
|
const CHANNEL = process.env.ASSISTANT_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : 'chromium');
|
||||||
|
const KEEP_ARTIFACTS = process.argv.includes('--keep-artifacts');
|
||||||
|
const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[char]));
|
||||||
|
|
||||||
|
function browserClient(home) {
|
||||||
|
const child = spawn(process.execPath, [path.join(ROOT, 'tools/learning/browser-worker.mjs')], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ASSISTANT_LEARNING_HOME: home,
|
||||||
|
ASSISTANT_BROWSER_CHANNEL: CHANNEL,
|
||||||
|
ASSISTANT_BROWSER_HEADLESS: '1',
|
||||||
|
},
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let sequence = 0;
|
||||||
|
let stderr = '';
|
||||||
|
let exitResult;
|
||||||
|
let resolveExit;
|
||||||
|
const exited = new Promise(resolve => { resolveExit = resolve; });
|
||||||
|
const requests = new Map();
|
||||||
|
const output = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||||
|
output.on('line', line => {
|
||||||
|
let reply;
|
||||||
|
try { reply = JSON.parse(line); }
|
||||||
|
catch {
|
||||||
|
const error = new Error('Worker stdout contains a non-JSON protocol line');
|
||||||
|
for (const request of requests.values()) { clearTimeout(request.timer); request.reject(error); }
|
||||||
|
requests.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = requests.get(reply.id);
|
||||||
|
if (!request) return;
|
||||||
|
requests.delete(reply.id);
|
||||||
|
clearTimeout(request.timer);
|
||||||
|
if (reply.error) {
|
||||||
|
const error = new Error(reply.error.message);
|
||||||
|
error.code = reply.error.code;
|
||||||
|
request.reject(error);
|
||||||
|
} else request.resolve(reply.result);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-12_000); });
|
||||||
|
child.on('error', error => {
|
||||||
|
for (const request of requests.values()) { clearTimeout(request.timer); request.reject(error); }
|
||||||
|
requests.clear();
|
||||||
|
});
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
exitResult = { code, signal };
|
||||||
|
for (const request of requests.values()) {
|
||||||
|
clearTimeout(request.timer);
|
||||||
|
request.reject(new Error(`Worker exited before replying, code=${code}. ${stderr}`));
|
||||||
|
}
|
||||||
|
requests.clear();
|
||||||
|
resolveExit(exitResult);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
call(method, params = {}) {
|
||||||
|
const id = `worker-test-${++sequence}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
requests.delete(id);
|
||||||
|
reject(new Error(`Worker timed out: ${method}. ${stderr}`));
|
||||||
|
}, 100_000);
|
||||||
|
requests.set(id, { resolve, reject, timer });
|
||||||
|
child.stdin.write(JSON.stringify({ id, method, params }) + '\n', error => {
|
||||||
|
if (!error) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
requests.delete(id);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async close({ timeout = 15_000 } = {}) {
|
||||||
|
if (exitResult) return exitResult;
|
||||||
|
if (!child.stdin.writableEnded) child.stdin.end();
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
return await Promise.race([exited, new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error('Worker did not release browsers after stdin EOF')), timeout);
|
||||||
|
})]);
|
||||||
|
} catch (error) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
output.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(TEMP_ROOT, { recursive: true });
|
||||||
|
const runRoot = await fs.mkdtemp(path.join(TEMP_ROOT, 'worker-regression-'));
|
||||||
|
const home = path.join(runRoot, 'learning');
|
||||||
|
const results = [];
|
||||||
|
let succeeded = false;
|
||||||
|
let slowRequestCount = 0;
|
||||||
|
let resolveSlowNavigation;
|
||||||
|
const slowNavigation = new Promise(resolve => { resolveSlowNavigation = resolve; });
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const url = new URL(request.url, 'http://127.0.0.1');
|
||||||
|
if (url.pathname === '/fixture/slow') {
|
||||||
|
slowRequestCount += 1;
|
||||||
|
// No response headers: verify is now waiting in its real browser goto,
|
||||||
|
// whose ordinary timeout is 20 seconds. EOF must interrupt it earlier.
|
||||||
|
resolveSlowNavigation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname !== '/fixture/search') { response.writeHead(404).end(); return; }
|
||||||
|
const keyword = escapeHtml(url.searchParams.get('q') || '初始');
|
||||||
|
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
response.setHeader('Set-Cookie', 'worker-test-session=retained; Max-Age=86400; Path=/; SameSite=Lax');
|
||||||
|
response.end(`<!doctype html><html lang="zh-CN"><head><title>本机学习工作进程验收</title></head>
|
||||||
|
<body><main><h1>材料搜索</h1><form method="get"><input data-testid="query" name="q" placeholder="搜索关键词"><button data-testid="search">搜索</button></form>
|
||||||
|
<div data-testid="results">${keyword} 的搜索结果</div><div data-testid="cookie-state">${request.headers.cookie?.includes('worker-test-session=retained') ? 'retained' : 'fresh'}</div>
|
||||||
|
<form><input name="phone" placeholder="手机号" value="13812345678"><button>验证码登录</button></form><p>13812345678</p><button data-testid="send">发送私信</button></main></body></html>`);
|
||||||
|
});
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const entryUrl = `http://127.0.0.1:${server.address().port}/fixture/search`;
|
||||||
|
const client = browserClient(home);
|
||||||
|
let restartedClient;
|
||||||
|
const passed = (name, detail = {}) => {
|
||||||
|
results.push({ name, passed: true, ...detail });
|
||||||
|
console.log(`ok ${results.length} - ${name}`);
|
||||||
|
};
|
||||||
|
const rejectCode = (method, params, code) => assert.rejects(() => client.call(method, params), error => error.code === code);
|
||||||
|
try {
|
||||||
|
const opened = await client.call('open', { task_id: 'regression-a', account_key: 'test-account', entry_url: entryUrl });
|
||||||
|
assert.equal(opened.status, 'awaiting_user');
|
||||||
|
await rejectCode('capture', { task_id: 'regression-a' }, 'AWAITING_USER');
|
||||||
|
passed('首次打开进入人工确认,未确认不能采集');
|
||||||
|
|
||||||
|
await rejectCode('open', { task_id: 'regression-b', account_key: 'test-account', entry_url: entryUrl }, 'ACCOUNT_BUSY');
|
||||||
|
passed('同一账号不能同时被两个学习任务持有');
|
||||||
|
|
||||||
|
const confirmation = await client.call('ready', { task_id: 'regression-a', identity_label: '隔离测试账号' });
|
||||||
|
assert.equal(confirmation.identity_verification, 'operator_confirmed');
|
||||||
|
assert.equal(confirmation.manual_confirmation, true);
|
||||||
|
const observation = await client.call('capture', { task_id: 'regression-a', note: '本机测试,无真实平台登录' });
|
||||||
|
assert.ok(!observation.text.includes('13812345678'));
|
||||||
|
assert.ok(!observation.elements.some(element => element.kind === 'fill' && /phone|手机号/.test(element.selector + ' ' + element.label)));
|
||||||
|
const image = await fs.readFile(observation.screenshot_path);
|
||||||
|
assert.equal(image.subarray(1, 4).toString(), 'PNG');
|
||||||
|
passed('人工确认来源如实记录,文本及候选排除敏感输入并生成实际 PNG');
|
||||||
|
|
||||||
|
await rejectCode('action', { task_id: 'regression-a', kind: 'click', selector: '[data-testid="send"]' }, 'WRITE_ACTION_BLOCKED');
|
||||||
|
passed('拒绝发送私信动作');
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ kind: 'fill', selector: '[data-testid="query"]', parameter: 'keyword' },
|
||||||
|
{ kind: 'click', selector: '[data-testid="search"]' },
|
||||||
|
{ kind: 'read', selector: '[data-testid="results"]', output_name: 'results' },
|
||||||
|
];
|
||||||
|
const verified = await client.call('verify', { task_id: 'regression-a', entry_url: entryUrl, steps,
|
||||||
|
parameters: { keyword: '木材' }, expected_outputs: { results: '木材' } });
|
||||||
|
assert.equal(verified.passed, true, JSON.stringify(verified.errors));
|
||||||
|
assert.equal(verified.os, process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : process.platform);
|
||||||
|
assert.ok(verified.outputs.results.some(item => item.text.includes('木材')));
|
||||||
|
assert.ok(verified.evidence.length >= 4);
|
||||||
|
passed('参数化填写 搜索 读回实际浏览器结果通过', { os: verified.os, browser_version: verified.browser_version });
|
||||||
|
|
||||||
|
const failed = await client.call('verify', { task_id: 'regression-a', entry_url: entryUrl, steps,
|
||||||
|
parameters: { keyword: '水泥' }, expected_outputs: { results: '钢材' } });
|
||||||
|
assert.equal(failed.passed, false);
|
||||||
|
assert.ok(failed.errors.length > 0);
|
||||||
|
assert.ok(failed.outputs.results.some(item => item.text.includes('水泥')));
|
||||||
|
passed('错误输出预期保留实际读回并判失败');
|
||||||
|
|
||||||
|
await rejectCode('verify', { task_id: 'regression-a', entry_url: entryUrl, steps,
|
||||||
|
parameters: { keyword: '木材' }, expected_outputs: { results: '' } }, 'INVALID_EXPECTED_OUTPUT');
|
||||||
|
passed('拒绝用空输出预期取得验证通过');
|
||||||
|
|
||||||
|
await client.call('close', { task_id: 'regression-a' });
|
||||||
|
const reopened = await client.call('open', { task_id: 'regression-c', account_key: 'test-account', entry_url: entryUrl });
|
||||||
|
assert.notEqual(reopened.session_id, opened.session_id);
|
||||||
|
assert.equal(reopened.status, 'awaiting_user');
|
||||||
|
await rejectCode('capture', { task_id: 'regression-c' }, 'AWAITING_USER');
|
||||||
|
await client.call('ready', { task_id: 'regression-c', identity_label: '隔离测试账号' });
|
||||||
|
const reused = await client.call('capture', { task_id: 'regression-c' });
|
||||||
|
assert.match(reused.text, /retained/);
|
||||||
|
const profiles = await fs.readdir(path.join(home, 'profiles'));
|
||||||
|
assert.equal(profiles.length, 1);
|
||||||
|
passed('关闭重开复用同一资料目录及 Cookie,新会话仍需人工确认');
|
||||||
|
|
||||||
|
await rejectCode('not-a-method', {}, 'UNKNOWN_METHOD');
|
||||||
|
const status = await client.call('status', { task_id: 'regression-c' });
|
||||||
|
assert.equal(status.status, 'observing');
|
||||||
|
passed('JSONL 错误响应有代码且后续请求继续正常');
|
||||||
|
|
||||||
|
await client.call('close', { task_id: 'regression-c' });
|
||||||
|
assert.equal((await client.call('status', { task_id: 'regression-c' })).status, 'closed');
|
||||||
|
passed('显式关闭释放会话,状态如实返回关闭');
|
||||||
|
|
||||||
|
await client.call('open', { task_id: 'regression-eof', account_key: 'test-account', entry_url: entryUrl });
|
||||||
|
await client.call('ready', { task_id: 'regression-eof', identity_label: '隔离测试账号' });
|
||||||
|
const interruptedVerify = client.call('verify', { task_id: 'regression-eof',
|
||||||
|
entry_url: new URL('/fixture/slow', entryUrl).href, steps,
|
||||||
|
parameters: { keyword: '木材' }, expected_outputs: { results: '木材' },
|
||||||
|
}).then(result => ({ result }), error => ({ error: error.message }));
|
||||||
|
let navigationTimer;
|
||||||
|
try {
|
||||||
|
await Promise.race([slowNavigation, new Promise((_, reject) => {
|
||||||
|
navigationTimer = setTimeout(() => reject(new Error('Verify never reached the pending local navigation')), 8_000);
|
||||||
|
})]);
|
||||||
|
} finally { clearTimeout(navigationTimer); }
|
||||||
|
const closingStarted = Date.now();
|
||||||
|
const exited = await client.close({ timeout: 8_000 });
|
||||||
|
const elapsed = Date.now() - closingStarted;
|
||||||
|
assert.deepEqual(exited, { code: 0, signal: null });
|
||||||
|
assert.ok(elapsed < 8_000, `EOF shutdown took ${elapsed} ms`);
|
||||||
|
const interrupted = await interruptedVerify;
|
||||||
|
assert.ok(interrupted.error || interrupted.result?.passed === false, 'Interrupted verify must never pass');
|
||||||
|
assert.equal(slowRequestCount, 1, 'Interrupted navigation must not be replayed');
|
||||||
|
passed('verify执行中EOF立即中断并正常退出,不等待20秒导航超时或重放', { elapsed_ms: elapsed });
|
||||||
|
|
||||||
|
restartedClient = browserClient(home);
|
||||||
|
const afterEof = await restartedClient.call('open', { task_id: 'regression-after-eof', account_key: 'test-account', entry_url: entryUrl });
|
||||||
|
assert.equal(afterEof.status, 'awaiting_user');
|
||||||
|
await restartedClient.call('ready', { task_id: 'regression-after-eof', identity_label: '隔离测试账号' });
|
||||||
|
const afterRestart = await restartedClient.call('capture', { task_id: 'regression-after-eof' });
|
||||||
|
assert.match(afterRestart.text, /retained/);
|
||||||
|
assert.equal((await fs.readdir(path.join(home, 'profiles'))).length, 1);
|
||||||
|
passed('EOF后新进程重开原资料目录,Cookie保留且浏览器锁释放');
|
||||||
|
|
||||||
|
succeeded = true;
|
||||||
|
await fs.writeFile(path.join(runRoot, 'report.json'), JSON.stringify({ passed: results.length, results }, null, 2));
|
||||||
|
console.log(JSON.stringify({ passed: results.length, browser_channel: CHANNEL, artifacts: KEEP_ARTIFACTS ? runRoot : 'removed after success' }));
|
||||||
|
} finally {
|
||||||
|
try { await client.close(); await restartedClient?.close(); }
|
||||||
|
finally { await new Promise(resolve => server.close(resolve)); }
|
||||||
|
if (succeeded && !KEEP_ARTIFACTS) {
|
||||||
|
// Only remove the exact directory created by this run under this workspace.
|
||||||
|
if (path.dirname(runRoot) !== TEMP_ROOT || !path.basename(runRoot).startsWith('worker-regression-')) {
|
||||||
|
throw new Error('Refusing to remove a path outside this test run');
|
||||||
|
}
|
||||||
|
await fs.rm(runRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 });
|
||||||
|
} else console.log(`验收证据保留在 ${runRoot}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
Loading…
Reference in New Issue