feat: add local learning workbench and PostgreSQL foundation

This commit is contained in:
张文涛 2026-10-22 18:18:57 +08:00
parent f76f166241
commit cd341549dd
70 changed files with 8687 additions and 39 deletions

View File

@ -0,0 +1,17 @@
---
description: 每轮工作同步维护开发进度与规划方案
alwaysApply: true
---
# 进度与规划维护
- 适用本项目每轮开发、修复、验证、讨论、澄清和方案调整。
- 开始时读取 `docs/开发进度.md` 当前状态及最新记录,核对 `docs/整体方案-v0.2.md` 的近期实施规划。
- 结束前,在 `docs/开发进度.md` 追加本轮记录,写明日期、目标、实际完成、涉及文件、验证结果、未完成项及提交/推送状态;无代码变更时明确说明。
- 优化建议、设计决定、优先级和后续实施步骤更新到 `docs/整体方案-v0.2.md`,保持唯一当前规划;调整现有条目,不另建互相冲突的计划。
- 进度记录引用方案中的下一步,避免复制维护两份完整待办;新事实改变旧计划时,注明调整原因和影响。
- 历史记录保留,新记录可更正旧结论,不能用新结果抹掉失败或未完成状态。
- 区分已实现、静态检查、模拟测试、真实浏览器验证、真实平台验证、双端验证和正式发布;写明样本及环境边界,不把阶段测试次数累加或当作当前重跑结果。
- 未执行的验证写“未执行”;未提交或未推送如实记录。对话确认不等于代码实现,服务曾启动不等于当前仍在线。
- 不在文档中写入平台凭据、Cookie、验证码、数据库连接串、私有配置或原始个人资料。
- README 保持文档入口;涉及架构或流程的规划调整同步现有图解,普通进度追加无需重复改图。

15
.env.example Normal file
View File

@ -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

2
.gitignore vendored
View File

@ -20,6 +20,8 @@ venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
*.tsbuildinfo
dist/
build/
coverage/

7
AGENTS.md Normal file
View File

@ -0,0 +1,7 @@
# assistant 项目协作约定
开始本项目工作时,读取 [开发进度](docs/开发进度.md) 的当前状态与最新记录,以及 [整体方案](docs/整体方案-v0.2.md) 的当前实施规划。
每轮工作遵守 [进度与规划维护规则](.cursor/rules/progress-and-planning.mdc):结束前将本轮实际进展写入进度文档,将优化、设计决定与实施顺序更新到规划方案。纯讨论、澄清和验证轮次也要保留简明记录。
保护共享工作区已有改动。学习端首期由维护者与本地 Codex 协作学习;实际平台能力、测试、发布与交付状态必须按证据分别报告。

View File

@ -1,6 +1,6 @@
# assistant
面向国内商家的 AI 获客助手,当前处于产品与技术方案讨论阶段。
面向国内商家的 AI 获客助手,已开始建设本机学习与验证后台。
首版桌面端同时适配 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 分工、端形态、收费思路及首版验证范围。
## 当前路线
- 使用 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 工程作为运行依赖。
学习后台的具体迁入范围见 [模块来源与边界](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)

14
apps/admin/index.html Normal file
View File

@ -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>

26
apps/admin/package.json Normal file
View File

@ -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"
}
}

View File

@ -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 与独立来源读回仍需要后续真实验证。

View File

@ -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>;
}

51
apps/admin/src/App.tsx Normal file
View File

@ -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

39
apps/admin/src/api.ts Normal file
View File

@ -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),
};

View File

@ -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;
}

View File

@ -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>
</>;
}

View File

@ -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>
);
}

View File

@ -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>;
}

View File

@ -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>
</>;
}

View File

@ -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;
}

View File

@ -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>
</>;
}

View File

@ -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>;
}

View File

@ -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 };
}),
},
};
}

7
apps/admin/src/main.tsx Normal file
View File

@ -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 />);

View File

@ -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; } }

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

159
apps/admin/src/styles.css Normal file
View File

@ -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; } }

102
apps/admin/src/types.ts Normal file
View File

@ -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 }

18
apps/admin/tsconfig.json Normal file
View File

@ -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"]
}

16
apps/admin/vite.config.ts Normal file
View File

@ -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 },
});

1
apps/server/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Local maintainer application; cloud commercial services remain separate."""

142
apps/server/auth.py Normal file
View File

@ -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))

235
apps/server/db.py Normal file
View File

@ -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)

476
apps/server/learning.py Normal file
View File

@ -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

View File

@ -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

238
apps/server/main.py Normal file
View File

@ -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()

46
apps/server/postgres.sql Normal file
View File

@ -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$;

View File

@ -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

View File

@ -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()

View File

@ -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"])

View File

@ -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())

View File

@ -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)

106
apps/server/worker.py Normal file
View File

@ -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)

25
compose.yaml Normal file
View File

@ -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:

1
docs/Skills.md Normal file
View File

@ -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.

122
docs/开发进度.md Normal file
View File

@ -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 协作的小红书实际链路学习。
提交/推送:正在执行,成功后回填实际代码提交与远端核验结果。

View File

@ -4,7 +4,17 @@
整理日期: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. 建议采用的整体路线
@ -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. 商家使用过程
1. 安装软件,登录我们平台的产品账号。
2. 添加平台账号,在软件管理的浏览器中自行登录抖音等平台。
2. 添加平台账号,在软件打开的独立浏览器中自行扫码,或在平台官方页面输入手机号和验证码完成登录。软件核验登录状态和账号后保存本地账号映射;再次运行先检查原会话,失效时再请商家登录。
3. 创建商品档案:商品、使用场景、服务地区、客户类型和排除条件;未设置的地区等条件不自动补齐。
4. 配置搜索关键词或推荐流入口、任务范围、预算及跟进方式。
5. 启动任务,查看候选视频、主页判断、评论线索和筛选依据。
@ -64,8 +74,12 @@ flowchart TD
```mermaid
flowchart LR
subgraph DEV[维护者环境]
LEARN[学习与调试工具] --> CHECK[参数化与回归验证]
CHECK --> PKG[版本化能力包]
LEARN[限定目标并观察取证] --> KNOW[字段、状态、分支与缺口]
KNOW --> CANDIDATE[候选能力与验证用例]
CANDIDATE --> CHECK[固定规则校验]
CHECK --> VERIFY[变参试跑与结果核对]
VERIFY --> PKG[发布不可变能力版本]
VERIFY -. 验证失败后修订 .-> CANDIDATE
end
subgraph LOCAL[商家电脑]
UI[Electron 商家界面] --> RUN[任务与链路执行器]
@ -85,6 +99,7 @@ flowchart LR
end
PKG --> RELEASE
RELEASE --> RUN
RUN -. 失败证据回流维护端 .-> LEARN
UI --> API
RUN --> AI
MS --> AI
@ -96,7 +111,9 @@ flowchart LR
桌面采用 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 安装包中分别验证。
@ -108,6 +125,18 @@ flowchart LR
固定执行层负责打开页面、点击、输入、滚动、等待和读取,首选 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 身份状态文档](https://playwright.dev/docs/auth)。保存目录不保证所有内存或临时会话状态在重启后可用,重新打开后的有效性检查始终保留。
### 4.3 本地与云端的数据边界
本地 SQLite 保存平台账号映射、任务、检查点、候选内容、线索、证据及动作结果。浏览器登录资料保留在对应本地目录。
商家端独立的业务 SQLite 保存平台账号映射、任务、检查点、候选内容、线索、证据及动作结果。账号映射包含浏览器目录或会话分区引用和最后核验状态;平台登录资料保留在对应本地浏览器资料中。维护者学习端使用独立 PostgreSQL 学习库和检索库,两端分别管理生命周期和数据,不共享商家登录资料。
云端 PostgreSQL 保存产品账号、设备权益、订单、额度账本、模型请求计量以及版本清单。模型分析按任务提交所需文本或图片。
第一版以本地线索为主,不默认建设全量线索同步。网页后台先服务产品管理、订单、用量和发布;如果后续要在手机上查看线索,再增加明确的线索同步功能。
学习维护所需的失败回流,首版通过诊断导出与维护端导入实现,内容限定为相关能力版本、必要输入、失败位置及对应页面证据片段。诊断资料和业务线索同步分别设计,浏览器登录资料仍保留在本地账号目录。
供应商模型密钥保存在服务端。客户端通过我们自己的 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.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 句柄不能作为跨运行的长期身份。
### 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)。
- **OpenAdapt Flow:** 重点研究录制、编译、参数化和执行后检查。当前开发位于 [OpenAdapt 主仓库的 flow 包](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/flow)。首版先参考产物设计,验证通过后再决定是否接入 Python 运行时。
- **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)。这些能力可供参考,不构成必须采用它的理由。
@ -167,7 +256,41 @@ A2A 与 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 判断与调用成本
@ -235,7 +358,7 @@ A2A 与 browser_use 提供设计和实现经验,本项目独立定义需要的
关闭主窗口与退出程序分别定义,Windows 托盘和 macOS 菜单栏、Dock 行为通过系统适配处理,界面明确显示任务是否仍在运行。退出程序会停止本地执行;本地任务不会在电脑休眠或关机后继续。两端分别验证恢复唤醒后的浏览器与账号核验。断网或云端不可用时停止开启新的分析和依赖分析的后续动作,保留检查点及未决请求,恢复后对账再继续。
任务固定使用创建时的能力包版本;运行中的任务不热切换脚本。发布记录至少包含版本、校验信息和兼容范围,升级或回滚后从支持的检查点继续;不兼容时明确暂停。
任务固定使用创建时的能力包版本;运行中的任务不热切换脚本。发布记录至少包含版本、校验信息和兼容范围。客户端升级或回滚后,先确认其支持任务原有的能力包版本,再从支持的检查点继续;不兼容时明确暂停。需要采用不同能力版本时,按第 5.5 节建立关联的新任务并复用可兼容进度,保留跨任务动作去重。
## 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. 首轮验收要回答的问题
- 一条能力从页面证据、字段与分支说明到实现和用例是否可追溯,尚未覆盖哪些状态。
- 固定校验、变参试跑和版本发布是否分别有记录;更换关键词、对象和列表状态后,输出与实际页面是否仍对应。
- 学习端能否按能力版本、参数、用例、系统和浏览器回看独立验证记录,修订与重跑是否保留原结论。
- 每类页面是否真的能完成操作和读取,读出的对象、文本与来源是否对应。
- 同行评论和需求作者两条业务路径能否分别形成可回看的线索。
- 内容、角色和意图判断各自有哪些误判、漏判和信息不足的样本。
- 每千条分析、每批任务和每条人工确认线索的模型费用与总耗时是多少。
- 登录失效、页面变化、崩溃、额度耗尽和人工接管后,是否能继续或明确停止。
- 平台登录能否完全由商家在官方页面完成,登录信息是否排除在日志、证据和模型输入之外;同目录重开、软件重启和升级后是否复用并重新核验会话。
- 登录后同会话移交、浏览器最小化、手动关闭及重新显示的行为在两端是否符合定义,当前账号身份是否始终与任务对应。
- 出现未覆盖分支或链路失效后,能否保留证据、修订候选并完成回归;是否避免在旧任务中直接替换未验证实现。
- 跟进结果能否核对,重复对象和不确定结果会怎样处理。
- 客户端、服务端和供应商用量是否能对账,重试是否导致重复扣费。
- 安装到已确认的 Windows 与 macOS 系统版本、架构的干净目标环境后,浏览器、原生依赖、执行进程、升级和数据保留是否正常。
- 相同功能在 Windows 与 macOS 是否均通过,窗口关闭、完整退出、快捷键和休眠恢复是否符合各端定义。
- 候选基础模块提取后是否具备独立标识、目录与进程生命周期,是否仍意外依赖原校园数据、旧工程数据库或客户端供应商密钥。
这些是建议验收方向。目前没有实测结果,也没有确定准确率、日处理量或交付周期。
这些是产品验收方向。本机学习基础、PG 存储和隔离示例已有工程验证,结果与边界见开发进度文档;真实平台准确率、日处理量、双端业务效果和交付周期尚未确定。
## 12. 建议的后续工程结构
以下为后续组织方式,当前尚未创建这些应用和包:
当前已建立 `apps/admin/` 学习工作台、`apps/server/` 本机维护 API 和 `tools/learning/` 浏览器工作进程。后续随业务扩展拆分,建议结构如下;未建立的包不代表已有实现:
```text
apps/desktop/ 商家桌面界面与主进程
apps/server/ 产品账号、AI 网关、权益、订单、计量和发布 API
apps/admin/ 订单、用量、版本与运营管理
apps/server/ 已有本机学习 API;云端产品 API 后续独立部署
apps/admin/ 已有维护者学习后台;商业运营模块后续补充
tools/learning/ 维护者的学习、调试和验证入口
packages/contracts/ 任务、能力包、线索、错误和模型结果定义
packages/runner/ 编排、检查点、动作记录和预算
@ -312,7 +446,7 @@ capabilities/ 已验证能力包的清单与版本资产
docs/ 方案与运行说明
```
初期可以保留较少实际包,先明确边界再按代码规模拆分。首轮只需要基础浏览器验证、执行器和最小桌面界面;后台在商业闭环阶段完善。
初期保留较少实际包,先明确边界再按代码规模拆分。当前先完成维护者学习后台及执行器,再接商家桌面端;商业后台在商业闭环阶段完善。
## 13. 本轮建议与仍需验证的选择
@ -321,17 +455,54 @@ docs/ 方案与运行说明
- 首版同时适配 Windows 和 macOS,桌面框架采用 Electron。
- 保留搜索与推荐流入口,以及同行评论、作者本人有需求两条业务路径。
- 沿用先学习、固化链路、再重复执行的思路,控制运行时的模型调用。
- 学习端独立保留验证记录,关联能力版本、用例、参数、环境、实际结果和证据;商家在平台官方页面自行完成登录。
- 模型由我们在运营后台按用途配置,不绑定某个厂商版本,也不要求默认使用最强模型。
- A2A 与 browser_use 仅作为参考,不设优先复用要求,实现选择以当前产品需求为准。
- 学习后台以 A2A learning 的适用模块为基础裁剪适配,详见第 5.8 节;其他 A2A 与 browser-use Agent 资产按需参考,`browser_use` 桌面壳按第 4.4 节评估提取。
### 13.2 当前实施建议
以 Playwright 固定执行为主线,围绕当前产品设计轻量执行器;Midscene 保持可选,按实际步骤需求比较。维护者负责能力包学习、验证和发布,云端负责模型网关与用量计量。A2A 与 browser_use 的设计和实现可按需参考。
React + TypeScript、SQLite、Node.js/TypeScript 执行器与服务端、PostgreSQL 是推荐组合。具体组件依据功能适配、运行表现和双平台分发维护成本决定,不预设旧代码保留或迁移目标。
学习端采用有界闭环:限定目标与预算,观察取证,整理字段、状态、分支和缺口,生成候选及用例,固定校验,变参试跑与结果核对,发布不可变版本;失败通过维护端修订和回归。先服务六类获客操作,不建设完整通用学习平台。
React + TypeScript、SQLite、Node.js/TypeScript 执行器与 PostgreSQL 是推荐组合;云端优先验证可采用的 FastAPI 认证与管理骨架,再新增本项目模块。桌面壳与后台可从已检查的测试工程中提取适用基础,具体组件依据功能适配、运行表现和双平台分发维护成本决定。
### 13.3 仍需验证的选择
首发内容平台、具体 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。
每轮新增优化先更新本节或其所属章节,说明解决的问题、实施顺序和验收依据;相应进度记录引用当前规划。产品目标变化时修订现有条目,避免叠加多个互相冲突的计划文件。

329
docs/方案图解.html Normal file
View File

@ -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 &amp; 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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>

11
infra/postgres/init.sql Normal file
View File

@ -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;

22
package.json Normal file
View File

@ -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" }
}

1654
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
packages:
- apps/admin

View File

@ -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; });

55
scripts/check_postgres.py Normal file
View File

@ -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

33
scripts/db.mjs Normal file
View File

@ -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.');

21
scripts/dev.mjs Normal file
View 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); });
}

View File

@ -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; });

View File

@ -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()

18
scripts/processes.mjs Normal file
View File

@ -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 })]);
}
}

37
scripts/serve.py Normal file
View File

@ -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()

26
scripts/server.mjs Normal file
View File

@ -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; });
}

215
scripts/test-learning.mjs Normal file
View File

@ -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);
}

6
scripts/test-server.mjs Normal file
View File

@ -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; });

41
tools/learning/README.md Normal file
View File

@ -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 或上次确认视为当前账号自动核验结果。

View File

@ -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;
});
}

View File

@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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; });