diff --git a/.cursor/rules/progress-and-planning.mdc b/.cursor/rules/progress-and-planning.mdc new file mode 100644 index 0000000..b561050 --- /dev/null +++ b/.cursor/rules/progress-and-planning.mdc @@ -0,0 +1,17 @@ +--- +description: 每轮工作同步维护开发进度与规划方案 +alwaysApply: true +--- + +# 进度与规划维护 + +- 适用本项目每轮开发、修复、验证、讨论、澄清和方案调整。 +- 开始时读取 `docs/开发进度.md` 当前状态及最新记录,核对 `docs/整体方案-v0.2.md` 的近期实施规划。 +- 结束前,在 `docs/开发进度.md` 追加本轮记录,写明日期、目标、实际完成、涉及文件、验证结果、未完成项及提交/推送状态;无代码变更时明确说明。 +- 优化建议、设计决定、优先级和后续实施步骤更新到 `docs/整体方案-v0.2.md`,保持唯一当前规划;调整现有条目,不另建互相冲突的计划。 +- 进度记录引用方案中的下一步,避免复制维护两份完整待办;新事实改变旧计划时,注明调整原因和影响。 +- 历史记录保留,新记录可更正旧结论,不能用新结果抹掉失败或未完成状态。 +- 区分已实现、静态检查、模拟测试、真实浏览器验证、真实平台验证、双端验证和正式发布;写明样本及环境边界,不把阶段测试次数累加或当作当前重跑结果。 +- 未执行的验证写“未执行”;未提交或未推送如实记录。对话确认不等于代码实现,服务曾启动不等于当前仍在线。 +- 不在文档中写入平台凭据、Cookie、验证码、数据库连接串、私有配置或原始个人资料。 +- README 保持文档入口;涉及架构或流程的规划调整同步现有图解,普通进度追加无需重复改图。 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f7ce52 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 34fcbb7..728470b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ venv/ __pycache__/ *.py[cod] .pytest_cache/ +.ruff_cache/ +*.tsbuildinfo dist/ build/ coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..160941f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# assistant 项目协作约定 + +开始本项目工作时,读取 [开发进度](docs/开发进度.md) 的当前状态与最新记录,以及 [整体方案](docs/整体方案-v0.2.md) 的当前实施规划。 + +每轮工作遵守 [进度与规划维护规则](.cursor/rules/progress-and-planning.mdc):结束前将本轮实际进展写入进度文档,将优化、设计决定与实施顺序更新到规划方案。纯讨论、澄清和验证轮次也要保留简明记录。 + +保护共享工作区已有改动。学习端首期由维护者与本地 Codex 协作学习;实际平台能力、测试、发布与交付状态必须按证据分别报告。 diff --git a/README.md b/README.md index fee1292..10c38b4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..7cb92ae --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,14 @@ + + + + + + + + Assistant 学习工作台 + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..a2dda49 --- /dev/null +++ b/apps/admin/package.json @@ -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" + } +} diff --git a/apps/admin/source-provenance.md b/apps/admin/source-provenance.md new file mode 100644 index 0000000..b1353e1 --- /dev/null +++ b/apps/admin/source-provenance.md @@ -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 与独立来源读回仍需要后续真实验证。 diff --git a/apps/admin/src/A2AWorkbench.tsx b/apps/admin/src/A2AWorkbench.tsx new file mode 100644 index 0000000..0be9378 --- /dev/null +++ b/apps/admin/src/A2AWorkbench.tsx @@ -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 = ; + if (category === 'tasks' && detailId) content = ; + if (category === 'overview') content = ; + if (category === 'candidates') content = ; + if (category === 'validations') content = ; + return
+ +
学习管理 / {({ tasks: '学习批次', overview: '账本总览', candidates: '能力实体', validations: '脚本测试' } as Record)[category] || '学习批次'}
{content}
+
; +} diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx new file mode 100644 index 0000000..26f1f1a --- /dev/null +++ b/apps/admin/src/App.tsx @@ -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(setup ? '/auth/setup' : '/auth/login', { username: values.username.trim(), password: values.password }); + onAuthenticated(response); + } catch (err) { setError(errorText(err)); } + finally { setBusy(false); } + }; + return
AAssistant
LEARNING WORKBENCH

把页面操作
变成可验证的能力

从一个明确目标开始,保留观察与证据,整理可复用步骤,并检查不同参数下的实际结果。

  1. 01
    观察真实页面维护者在官方页面自行登录
  2. 02
    固化操作链路候选版本与证据一同保留
  3. 03
    记录每次验证校验、试跑与发布条件分别核对
本机维护工作台 · 学习资料独立保存
维护者入口{setup ? '设置维护者账号' : '登录学习工作台'}{setup ? '首次使用,为这个学习工作台创建一个维护者账号。' : '使用工作台账号查看学习任务与验证记录。'} + {error && } +
void submit(values)} size="large"> + + + {setup && ({ validator(_, value) { return !value || getFieldValue('password') === value ? Promise.resolve() : Promise.reject(new Error('两次填写的密码不一致')); } })]}>} + +
这是维护工作台的账号,与小红书等平台账号分开。平台手机号、密码和验证码由你直接在官方页面填写。
; +} + +export function App() { + const [dark, setDark] = useState(savedTheme); + const [auth, setAuth] = useState(); + 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('/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 + {loading ?
正在连接学习服务
: error && !auth ?
: auth?.authenticated ? <> setDark(value => !value)} onLogout={() => void logout()} />{error &&
setError('')} />
} : auth ? : null} +
; +} diff --git a/apps/admin/src/a2a-learning.css b/apps/admin/src/a2a-learning.css new file mode 100644 index 0000000..5ae1221 --- /dev/null +++ b/apps/admin/src/a2a-learning.css @@ -0,0 +1,1446 @@ +:root { + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', 'Noto Sans SC', + 'Microsoft YaHei', sans-serif; + color: #1d1d1f; + background: #ffffff; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + --muted: #64646b; + --line: #e8e8ed; + --blue: #0066cc; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + font-size: 14px; + line-height: 1.6; +} +button, +input, +textarea { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; +} +a { + color: var(--blue); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +svg { + flex-shrink: 0; +} +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: 24px; + line-height: 1.4; + font-weight: 600; + letter-spacing: -1px; +} +h2 { + font-size: 18px; + font-weight: 500; + line-height: 1.5; +} +h3 { + font-size: 16px; + font-weight: 500; +} +::selection { + background: #d6e9ff; +} +:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 4px; +} + +.app-shell { + min-height: 100vh; +} +.sidebar { + width: 208px; + position: fixed; + inset: 0 auto 0 0; + z-index: 20; + display: flex; + flex-direction: column; + background: #f8f9fb; + border-right: 1px solid var(--line); + padding: 16px; +} +.brand { + display: flex; + align-items: center; + gap: 8px; + min-height: 40px; + flex-shrink: 0; + margin-bottom: 16px; + padding: 0 8px; + font-size: 16px; + font-weight: 600; +} +.brand-mark { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: #e7effb; + color: #245cb6; +} +.nav-items { + display: flex; + flex-direction: column; + gap: 16px; + scrollbar-width: thin; + scrollbar-color: #d0d4dc transparent; +} +.nav-item { + position: relative; + width: 100%; + border: 0; + background: transparent; + min-height: 32px; + display: flex; + align-items: center; + gap: 8px; + text-align: left; + padding: 4px 12px; + color: #454a55; + border-radius: 8px; + font-size: 14px; + line-height: 24px; +} +.nav-item:hover { + background: #eceff3; +} +.nav-item.active { + background: #e7effb; + color: #1e56b0; + font-weight: 500; +} +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 0 4px 4px 0; + background: #2c65c8; +} +.nav-item:focus-visible, +.sidebar-account:focus-visible { + outline: 2px solid var(--blue); + outline-offset: -2px; +} +.nav-item svg { + color: #6d7582; +} +.nav-item.active svg { + color: currentColor; +} +.nav-group, +.nav-subitems { + display: flex; + flex-direction: column; + gap: 4px; +} +.nav-group-label { + padding: 0 12px; + color: #737b88; + font-size: 12px; + font-weight: 500; + line-height: 16px; +} +.sidebar-footer { + margin-top: auto; + padding-top: 16px; +} +.sidebar-identity { + border-top: 1px solid var(--line); + padding-top: 8px; +} +.sidebar-account { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) 16px; + align-items: center; + gap: 8px; + width: 100%; + min-height: 56px; + padding: 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: #1d1d1f; + text-align: left; +} +.sidebar-account:hover { + background: #eceff3; +} +.sidebar-account-avatar { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 1px solid #dfe3ea; + border-radius: 8px; + background: #ffffff; + color: #5b6574; +} +.sidebar-account > svg { + color: var(--muted); +} +.sidebar-account-label { + display: flex; + flex-direction: column; + min-width: 0; + overflow-wrap: anywhere; +} +.sidebar-account-name { + font-size: 14px; + font-weight: 500; + line-height: 20px; +} +.sidebar-account-meta { + color: var(--muted); + font-size: 12px; + line-height: 20px; +} +.sidebar-note { + color: var(--muted); + font-size: 12px; + padding: 16px 0 0; +} +.workspace { + margin-left: 208px; +} +.topbar { + height: 48px; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: var(--muted); + font-size: 12px; +} +.topbar .breadcrumbs { + font-size: 14px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.identity { + display: flex; + gap: 16px; + align-items: center; + white-space: nowrap; +} +.mobile-menu { + display: none; + border: 0; + padding: 8px; + background: transparent; +} +.page { + padding: 16px 24px; + max-width: 1760px; + margin: 0 auto; +} +.page-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} +.subtitle { + margin-top: 0; + color: var(--muted); +} +.page-heading > div:first-child, +.learning-heading-text { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px 16px; + min-width: 0; + overflow-wrap: anywhere; +} +.page-heading > .learning-heading-context { + align-items: center; + flex-wrap: nowrap; +} +.page-heading .learning-back { + margin-bottom: 0; + flex-shrink: 0; +} +.heading-action { + flex-shrink: 0; + max-width: 100%; + padding-top: 0; +} +.facts { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + margin-top: 16px; + color: var(--muted); + font-size: 12px; +} +.filter-bar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 16px; +} +.filter-bar .search { + width: 280px; + margin-left: auto; +} +.filter-bar > .ant-btn, +.learning-overview-link { + min-height: 32px; + display: inline-flex; + align-items: center; +} +.filter-bar > .ant-btn { + padding: 0 12px; +} +.quiet-control .ant-select-selector, +.quiet-button.ant-btn-default { + background: #f5f5f7 !important; + border-color: #f5f5f7 !important; +} +.quiet-button.ant-btn-default:hover { + color: #1d1d1f; + background: #e8e8ed !important; +} +.content-split { + display: grid; + grid-template-columns: minmax(0, 1fr) 288px; + gap: 16px; +} +.detail-rail { + border-left: 1px solid var(--line); + padding-left: 16px; + min-width: 0; +} +.detail-rail h2 { + margin-bottom: 16px; +} +.section { + margin-bottom: 24px; +} +.section-heading { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + margin-bottom: 16px; +} +.section-heading .meta { + color: var(--muted); + font-size: 12px; +} +.metadata { + margin: 0; + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 8px 16px; + align-items: baseline; +} +.metadata dt { + margin: 0; + font-size: 12px; + color: var(--muted); +} +.metadata dt:first-child { + margin-top: 0; +} +.metadata dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} +.detail-rail .metadata { + grid-template-columns: 72px minmax(0, 1fr); +} +.muted { + color: var(--muted); +} +.small { + font-size: 12px; +} +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + word-break: break-all; +} +.inline-icon { + display: inline-flex; + align-items: center; + gap: 8px; +} +.footer-note { + border-top: 1px solid var(--line); + display: flex; + justify-content: space-between; + gap: 16px; + padding-top: 16px; + color: var(--muted); + font-size: 12px; +} +.link-button { + border: 0; + padding: 0; + background: transparent; + color: var(--blue); + text-align: left; + min-height: 24px; + display: inline-flex; + align-items: center; +} +.link-button:hover { + text-decoration: underline; +} +.link-button:disabled { + color: #8e8e93; + text-decoration: none; +} +.plain-table { + width: 100%; + border-collapse: collapse; + text-align: left; +} +.plain-table th { + font-weight: 400; + color: var(--muted); + font-size: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--line); + white-space: nowrap; +} +.plain-table td { + padding: 8px 12px; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} +.plain-table.compact td { + padding: 8px 12px; +} +.table-wrap { + min-width: 0; + max-width: 100%; + overflow-x: auto; +} +.clickable-row { + cursor: pointer; +} +.clickable-row:hover { + background: #f9f9fb; +} +.clickable-row.selected { + background: #f5f5f7; +} +.state-line { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 16px 0; + color: var(--muted); +} +.state-line.error { + color: #a12622; +} +.error-message { + padding: 16px 0; + color: #a12622; + overflow-wrap: anywhere; +} +.empty-state { + max-width: 480px; + padding: 48px 0; + margin: auto; + text-align: center; +} +.empty-state svg { + color: #8e8e93; + margin-bottom: 16px; +} +.empty-state h2 { + margin-bottom: 8px; +} +.empty-state p { + color: var(--muted); + margin-bottom: 16px; +} +.code-block { + overflow-x: auto; + margin: 16px 0; + padding: 16px; + background: #f5f5f7; + border-radius: 8px; + line-height: 1.6; + font: + 12px/1.7 ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} +.property-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.form-stack { + display: flex; + flex-direction: column; + gap: 16px; +} +.field-label { + display: block; + margin-bottom: 8px; +} +.field-caption { + font-size: 12px; + color: var(--muted); + margin-top: 8px; +} +.button-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.status-text { + color: var(--muted); + font-size: 12px; +} +.status-text.blue { + color: var(--blue); +} +.event-list { + padding: 0; + margin: 0; + list-style: none; +} +.event-list li { + padding: 8px 0; + border-bottom: 1px solid var(--line); +} +.event-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.status-dot { + width: 8px; + height: 8px; + background: #8e8e93; + border-radius: 50%; + display: inline-block; + margin-right: 8px; +} +.status-dot.active { + background: var(--blue); +} +.status-dot.success { + background: #287d45; +} +.status-dot.error { + background: #a12622; +} +.precheck-list { + list-style: none; + margin: 0; + padding: 0; +} +.precheck-list li { + margin-bottom: 16px; +} +.precheck-list .muted { + font-size: 12px; + display: block; + margin-top: 4px; +} +.login-screen { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; + background: #f5f5f7; +} +.login-panel { + width: min(400px, 100%); + padding: 24px; + background: white; + border-radius: 16px; +} +.login-panel h1 { + margin-bottom: 8px; +} +.login-panel .intro { + margin-bottom: 24px; + color: var(--muted); +} +.login-panel .ant-btn { + width: 100%; + margin-top: 8px; +} +.login-panel .field-caption { + margin-top: 16px; +} +.password-login h1 { + margin-bottom: 16px; +} +.password-login .ant-btn { + margin-top: 0; +} +.password-login .error-message { + padding: 0; + margin-bottom: 16px; +} +.account-toolbar [role='status'] { + min-width: 0; + overflow-wrap: anywhere; +} +.account-toolbar ~ .table-fill .table-wrap .button-row { + flex-wrap: nowrap; +} +.account-form { + padding-top: 8px; +} +.account-form .property-grid > .ant-form-item { + min-width: 0; +} +.account-form .ant-select { + width: 100%; +} +.account-form > .ant-form-item:last-child { + margin-bottom: 0; +} +.account-form .error-message { + padding: 0; +} +.scope-text { + overflow-wrap: anywhere; +} +.list-reset { + padding: 0 0 0 16px; + margin: 16px 0; +} +.list-reset li { + margin: 8px 0; +} +.separator { + height: 1px; + background: var(--line); + margin: 16px 0; +} +.test-form .ant-input-number { + width: 100%; +} +.ant-tabs-nav { + margin-bottom: 16px !important; +} +.ant-tabs-tab { + padding: 8px 0 !important; +} +.ant-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.ant-btn .ant-btn-icon { + display: flex; + align-items: center; +} +.ant-table-wrapper .ant-table-tbody > tr > td { + border-color: var(--line); +} +.ant-table-wrapper .ant-table-thead > tr > th::before { + display: none !important; +} +.ant-drawer .ant-drawer-header, +.ant-drawer .ant-drawer-body { + padding: 16px 24px; +} +.ant-modal .ant-modal-content, +.ant-select-dropdown, +.ant-tooltip .ant-tooltip-inner, +.ant-dropdown .ant-dropdown-menu { + box-shadow: none !important; +} +.ant-select-dropdown { + border: 1px solid var(--line); +} +.ant-modal .ant-modal-content { + border: 1px solid var(--line); +} +.ant-input-password, +.ant-input-affix-wrapper { + box-shadow: 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; +} + +.learning-back { margin-bottom: 16px; } +.learning-form { max-width: 800px; } +.learning-form, .learning-node-detail { min-width: 0; } +.learning-scope-summary { overflow-wrap: anywhere; } +.learning-form .ant-input-number { width: 100%; } +.learning-form-title { margin-bottom: 16px; } +.learning-budget-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.learning-scope-summary { padding: 16px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.learning-fact-line { margin-bottom: 8px; } +.campaign-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} +.campaign-state-tag.ant-tag { + margin: 0; + padding: 0 8px; + font-size: 14px; + line-height: 24px; + flex-shrink: 0; +} +.campaign-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 24px; + min-width: 0; + margin-bottom: 16px; +} +.campaign-summary-item { + display: inline-flex; + align-items: baseline; + gap: 8px; + color: var(--muted); + white-space: nowrap; +} +.campaign-summary-item > strong { + color: #1d1d1f; + font-size: 14px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} +.campaign-summary-issue { + font-size: 14px; + white-space: nowrap; +} +.retrieval-panel-body { + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} +.retrieval-panel-body:focus-visible { + outline-offset: -2px; +} +.retrieval-description { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + align-items: baseline; + gap: 8px 16px; + margin: 0 0 24px; +} +.retrieval-description-row { + display: contents; +} +.retrieval-description dt { + color: var(--muted); + font-size: 12px; +} +.retrieval-description dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} +.retrieval-text-list { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} +.retrieval-field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; +} +.retrieval-field-grid > section { + min-width: 0; +} +.retrieval-field-grid h3 { + margin-bottom: 8px; +} +.retrieval-quality, +.script-assembly-state { + color: var(--muted); + font-size: 12px; +} +.retrieval-copy-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 32px; +} +.retrieval-copy-status { + min-height: 24px; + margin: 8px 0; + font-size: 14px; + line-height: 24px; +} +.retrieval-index-text.code-block { + margin: 0; + font-family: inherit; + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.retrieval-index-drawer + .retrieval-index-drawer { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--line); +} +.parameter-name, +.parameter-key, +.retrieval-field-grid td > small, +.script-parameter-table td > small { + display: block; +} +.parameter-key { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + overflow-wrap: anywhere; +} +.retrieval-field-grid td > small, +.script-parameter-table td > small { + font-size: 12px; + overflow-wrap: anywhere; +} +.parameter-boundary { + min-width: 120px; + overflow-wrap: anywhere; +} +.parameter-usage { + min-width: 160px; +} +.parameter-usage > div + div { + margin-top: 8px; +} +.production-counts, +.production-step-status { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; +} +.production-counts > span, +.production-step-status > span { + white-space: nowrap; +} +.production-gap-reason { + white-space: normal; + overflow-wrap: anywhere; +} +.learning-wait { margin: 16px 0; } +.learning-empty { padding: 40px 0; } +.learning-inline-action { margin-left: 16px; } +.learning-recovery { border-bottom: 1px solid var(--line); margin-bottom: 16px; padding-bottom: 16px; } +.detail-drawer-trigger.ant-btn { + height: 32px; + min-height: 32px; + margin: 0; + padding: 0 8px; + gap: 8px; + color: var(--blue); + font-size: 14px; + vertical-align: middle; +} +.detail-drawer .ant-drawer-body { + min-height: 0; + overflow: auto; +} +.detail-drawer .code-block { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.learning-evidence-content { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 65vh; overflow-y: auto; } +.table-wrap { overscroll-behavior-x: contain; } +.consumer-filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} +.consumer-filter-row > .ant-select { min-width: 168px; } +.consumer-filter-row > .ant-input { width: 216px; } +.consumer-version { display: block; } +.consumer-detail-heading { min-height: 32px; } +.consumer-detail-heading .ant-tag { margin-inline-end: 0; } +.consumer-tab-scroll { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; } +.consumer-metadata { display: grid; grid-template-columns: 136px minmax(0, 1fr); gap: 8px 16px; margin-top: 0; } +.consumer-metadata dt, +.consumer-metadata dd { margin: 0; padding: 0; min-width: 0; overflow-wrap: anywhere; } +.consumer-hash { overflow-wrap: anywhere; } +.consumer-fact-section { margin: 24px 0 16px; } +.consumer-fact-section h3, +.consumer-section-title { margin: 16px 0 8px; font-size: 14px; } +.consumer-link-form { margin-bottom: 16px; } +.consumer-link-controls { display: flex; align-items: center; gap: 8px; } +.consumer-link-controls > .ant-select { flex: 1; min-width: 0; } +.consumer-choice-pagination .section-heading { margin-bottom: 0; } +.consumption-summary .plain-table td, +.consumption-summary .plain-table th { white-space: nowrap; } +.consumer-receipt-drawer .ant-drawer-body, +.consumer-feedback-drawer .ant-drawer-body { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } + +@media (min-width: 1280px) { + .app-shell { + height: 100vh; + min-height: 0; + overflow: hidden; + } + .sidebar { + height: 100%; + overflow: hidden; + } + .nav-items { + flex: 1; + min-height: 0; + overflow-y: auto; + } + .nav-item:focus-visible { + outline-offset: -2px; + } + .sidebar-footer { + flex-shrink: 0; + margin-top: 16px; + padding-top: 0; + } + .workspace { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + } + .page { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + width: 100%; + max-width: none; + overflow: auto; + } + .page > * { + flex-shrink: 0; + } + .page-list, + .table-fill { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + } + .page-list > *, + .table-fill > * { + flex-shrink: 0; + } + .page-list > .table-fill, + .page-list > .content-fill, + .page-list > .tabs-fill, + .table-fill > .table-scroll, + .table-fill > .table-fill { + flex: 1; + min-height: 0; + } + .table-scroll { + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + } + .table-scroll:focus-visible { + outline-offset: -2px; + } + .table-scroll .plain-table { + border-collapse: separate; + border-spacing: 0; + } + .table-scroll .plain-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: #ffffff; + } + .list-footer { + flex-shrink: 0; + } + .list-footer > div { + margin-top: 16px !important; + } + .list-footer .section-heading { + margin-bottom: 0; + } + .table-fill > .empty-state, + .table-fill > .learning-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 0; + width: 100%; + padding: 32px 0; + overflow: auto; + } + .content-fill { + flex: 1; + min-height: 0; + grid-template-rows: minmax(0, 1fr); + overflow: hidden; + } + .content-fill > * { + min-width: 0; + min-height: 0; + } + .content-fill > .detail-rail { + overflow: auto; + } + .schema-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: minmax(0, 1fr); + align-items: stretch; + gap: 16px; + min-height: 0; + } + .schema-grid > .table-fill { + align-self: stretch; + margin-bottom: 0; + } + .ledger-summary { + flex: 0 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + max-height: 32vh; + margin-bottom: 16px; + } + .ledger-summary > .section-heading { + flex-shrink: 0; + } + .tabs-fill { + flex: 1; + min-height: 0; + } + .tabs-fill > .ant-tabs-nav { + flex-shrink: 0; + } + .tabs-fill > .ant-tabs-body-holder { + flex: 1; + min-height: 0; + overflow: hidden; + } + .tabs-fill > .ant-tabs-body-holder > .ant-tabs-body { + height: 100%; + min-height: 0; + overflow: hidden; + } + .tabs-fill > .ant-tabs-body-holder > .ant-tabs-body > .ant-tabs-content-active { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; + } + .campaign-entries-drawer .ant-drawer-body, + .campaign-entry-browser .ant-drawer-body, + .campaign-production-drawer .ant-drawer-body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + } +} + +@media (max-width: 1280px) { + .learning-secondary-column { display: none; } +} +@media (max-width: 1024px) { + .learning-budget-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .learning-node-detail .content-split { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 680px) { + .learning-budget-grid { grid-template-columns: minmax(0, 1fr); } + .learning-form .property-grid { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 1152px) { + .content-split { + grid-template-columns: minmax(0, 1fr) 240px; + } + .page, + .topbar { + padding-left: 24px; + padding-right: 24px; + } + .filter-bar .search { + width: 240px; + } +} +@media (max-width: 960px) { + .content-split { + grid-template-columns: minmax(0, 1fr); + } + .detail-rail { + border-left: 0; + border-top: 1px solid var(--line); + padding: 16px 0 0; + } + .sidebar { + width: 176px; + } + .workspace { + margin-left: 176px; + } + .filter-bar .search { + margin-left: 0; + width: 100%; + } + .facts { + gap: 16px; + } +} +@media (max-width: 680px) { + .sidebar { + display: none; + } + .workspace { + margin-left: 0; + } + .topbar { + padding: 0 16px; + height: 56px; + } + .page { + padding: 24px 16px; + } + .mobile-menu { + display: inline-flex; + } + .topbar-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + .identity { + gap: 8px; + } + .identity .principal { + display: none; + } + h1 { + font-size: 24px; + letter-spacing: 0; + } + .page-heading { + gap: 16px; + } + .page-heading .heading-action { + padding-top: 0; + } + .property-grid { + grid-template-columns: 1fr; + } + .plain-table td { + padding: 16px 8px; + } + .plain-table th { + padding: 8px; + } + .footer-note { + flex-direction: column; + gap: 8px; + } + .login-panel { + padding: 32px 24px; + } + .filter-bar .ant-select { + flex: 1; + min-width: 140px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0s !important; + transition-duration: 0s !important; + scroll-behavior: auto !important; + } +} + +.current-publication-panel { + margin-top: 24px; + border-top: 1px solid var(--line); + padding-top: 16px; +} +.publication-status-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 16px; +} +.publication-metadata { + margin-bottom: 16px; +} + +.test-run-content { + grid-template-columns: minmax(0, 1fr) 224px; +} +.test-run-tabs { + min-width: 0; +} +.test-history-drawer .ant-drawer-body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.test-run-replay { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} +.test-replay-controls { + flex-shrink: 0; + padding: 8px 0 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--line); +} +.test-replay-toolbar, +.test-replay-status, +.test-replay-seek { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} +.test-replay-status { + gap: 8px; +} +.test-replay-toolbar h2 { + font-size: 16px; + white-space: nowrap; +} +.test-replay-count { + margin-left: auto; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.test-replay-seek { + margin-top: 8px; +} +.test-replay-seek .ant-slider { + flex: 1; + min-width: 80px; + margin: 8px; +} +.test-replay-seek > .meta { + flex-basis: 208px; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} +.test-run-workspace { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + min-height: 0; + overflow: hidden; +} +.test-run-steps, +.test-run-screen { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; +} +.test-run-workspace .section-heading { + flex-shrink: 0; + min-height: 32px; + margin-bottom: 8px; +} +.test-stream-message { + flex-shrink: 0; + margin-bottom: 8px; +} +.test-run-steps .plain-table th, +.test-run-steps .plain-table td { + padding: 8px; +} +.test-step-button { + text-align: left; +} +.test-step-selected td { + background: #f5f5f7; +} +.test-step-error { + color: #a13222; + margin-top: 4px; +} +.test-diagnostic-grid { + display: grid; + grid-template-columns: 104px minmax(0, 1fr) 104px minmax(0, 1fr); + gap: 8px 16px; + margin: 8px 0 16px; +} +.test-diagnostic-grid dt { + color: var(--muted); +} +.test-diagnostic-grid dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} +.test-diagnostic-address { + grid-column: 2 / -1; +} +.test-diagnostic-empty { + margin: 8px 0; +} +.test-diagnostic-network { + margin-bottom: 16px; +} +.test-diagnostic-network li { + display: flex; + flex-direction: column; + gap: 8px; + overflow-wrap: anywhere; +} +.test-log-notice { + margin-bottom: 8px; +} +.test-log-table .test-log-summary { + min-width: 184px; + max-width: 320px; + white-space: normal; +} +.test-screenshot-frame { + flex: 1; + display: flex; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid var(--line); + background: #f5f5f7; +} +.test-screenshot-frame img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.test-screenshot-button { + flex: 1; + display: block; + min-width: 0; + min-height: 0; + width: 100%; + padding: 0; + border: 0; + background: transparent; + cursor: zoom-in; +} +.test-screenshot-modal .test-screenshot-frame { + height: min(72vh, 720px); +} +.test-screenshot-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + text-align: center; +} +.test-screen-caption { + flex-shrink: 0; + display: flex; + justify-content: space-between; + gap: 8px; + min-height: 24px; + padding-top: 8px; +} +.test-run-tab-scroll { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} +.test-run-rail { + overflow: auto; +} +.test-result-section { + margin-top: 24px; + margin-bottom: 8px; +} +.test-verification-table { + margin-top: 16px; +} diff --git a/apps/admin/src/api.ts b/apps/admin/src/api.ts new file mode 100644 index 0000000..e88c544 --- /dev/null +++ b/apps/admin/src/api.ts @@ -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(path: string, method = 'GET', body?: unknown): Promise { + 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: (path: string) => request(path), + post: (path: string, body: unknown = {}) => request(path, 'POST', body), +}; diff --git a/apps/admin/src/capability-parameters.css b/apps/admin/src/capability-parameters.css new file mode 100644 index 0000000..f4d9951 --- /dev/null +++ b/apps/admin/src/capability-parameters.css @@ -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; +} diff --git a/apps/admin/src/components/DetailDrawer.tsx b/apps/admin/src/components/DetailDrawer.tsx new file mode 100644 index 0000000..f0ddaa8 --- /dev/null +++ b/apps/admin/src/components/DetailDrawer.tsx @@ -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 <> + + setOpen(false)} width={640} + className="detail-drawer" destroyOnHidden closeIcon={}> + {children} + + ; +} diff --git a/apps/admin/src/components/PageControls.tsx b/apps/admin/src/components/PageControls.tsx new file mode 100644 index 0000000..add27a4 --- /dev/null +++ b/apps/admin/src/components/PageControls.tsx @@ -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 ( +
+
+ + 第 {Math.floor(offset / LIST_PAGE_SIZE) + 1} 页 · 本页 {count} / 共 {total} 条 + +
+ {offset > 0 && ( + + )} + + +
+
+ {hasNext && atLimit && ( +

已到接口分页读取上限,请使用筛选缩小范围。

+ )} +
+ ); +} diff --git a/apps/admin/src/components/ScriptParameterTable.tsx b/apps/admin/src/components/ScriptParameterTable.tsx new file mode 100644 index 0000000..bcab242 --- /dev/null +++ b/apps/admin/src/components/ScriptParameterTable.tsx @@ -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 = { + string: '文本', integer: '整数', boolean: '是 / 否', object: '对象', array: '列表', +}; + +function typeName(parameter: CapabilityParameter): string { + const descriptor = parameter.typed_descriptor; + if (descriptor?.enum) return '选项'; + const formats: Record = { + 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 = { + '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 = { + 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
+ {catalog &&
+ {parameters.length} 项调用参数 + {parameters.length > 0 && 名称与说明:{described} / {parameters.length} 项齐全} + 执行引用:{complete ? '已核对' : '尚未完整核对'} + {!restricted && 业务参数覆盖:待核实} +
} + {restricted &&
+ 受限实例 · 不能作为通用能力使用 + 未参数化字段:{unmappedNames.length > 0 ? unmappedNames.join('、') : '尚未列明'} +
} +
+ + + + + + + + + + + {parameters.length > 0 ? parameters.map((parameter) => { + const limits = constraints(parameter, capability); + const conditional = !parameter.required && conditionalRequirements(parameter, capability).length > 0; + return + + + + + + + + ; + }) : } +
名称字段类型必填说明作用约束
{parameter.label ?? 未标注名称}{parameter.key}{typeName(parameter)}{parameter.required ? '必填' : conditional ? '条件必填' : '可选'} + {!parameter.schema_required && parameter.execution_required && 执行需要}{parameter.description ?? 未提供说明}{parameter.usages.length > 0 ? parameter.usages.map((usage, index) => +
{usageName(usage, capability)}
) + : {complete ? '未被固定执行步骤引用' : '执行用途待核实'}}
{limits.length > 0 ? limits.map((text) =>
{text}
) + : 未声明额外约束}
{empty}
+
+
; +} diff --git a/apps/admin/src/components/SidebarNavigation.tsx b/apps/admin/src/components/SidebarNavigation.tsx new file mode 100644 index 0000000..c5c58b3 --- /dev/null +++ b/apps/admin/src/components/SidebarNavigation.tsx @@ -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 <> +
学习管理
+ + ; +} diff --git a/apps/admin/src/components/States.tsx b/apps/admin/src/components/States.tsx new file mode 100644 index 0000000..1a5877f --- /dev/null +++ b/apps/admin/src/components/States.tsx @@ -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 ( +
+ + 正在读取记录… +
+ ); + if (!error) return null; + return ( +
+ +
+

{error}

+ +
+
+ ); +} + +export function EmptyState({ + title, + description, + action, +}: { + title: string; + description: string; + action?: ReactNode; +}) { + return ( +
+
+ ); +} + +export function ErrorMessage({ message }: { message: string | null }) { + return message ? ( +

+ {message} +

+ ) : null; +} diff --git a/apps/admin/src/components/TestRunHistory.tsx b/apps/admin/src/components/TestRunHistory.tsx new file mode 100644 index 0000000..203098d --- /dev/null +++ b/apps/admin/src/components/TestRunHistory.tsx @@ -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>(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 <> + + setOpen(false)} width={960} className="test-history-drawer" destroyOnHidden closeIcon={}> +
void resource.reload()} /> + {resource.data && !resource.loading && !resource.error && <>{items.length ?
+ + {items.map(run => )}
创建时间测试用例验证类型结果操作
{formatTime(run.created_at)}{run.sample_name || '未命名测试'}{run.kind === 'static' ? '静态校验' : '浏览器试跑'}{run.id === currentRunId ? 当前记录 : }
: } +
+ } +
+
+ ; +} diff --git a/apps/admin/src/components/common.tsx b/apps/admin/src/components/common.tsx new file mode 100644 index 0000000..02655bc --- /dev/null +++ b/apps/admin/src/components/common.tsx @@ -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(path: string | null) { + const [data, setData] = useState(); + 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(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
{title}{description && {description}}
{actions}
{children}
; +} +export function LoadError({ error, reload }: { error: string; reload: () => unknown }) { + return error ? void reload()}>重试} /> : null; +} +const statusLabels: Record = { + 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 {statusLabels[status] || status}; +} +export const platformLabels: Record = { 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
{JSON.stringify(value ?? {}, null, 2)}
; +} +export function NoRows({ description }: { description: string }) { return ; } +export function parseObject(value: string, label = '内容'): Record { + 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; +} diff --git a/apps/admin/src/learning-view-model.ts b/apps/admin/src/learning-view-model.ts new file mode 100644 index 0000000..8813333 --- /dev/null +++ b/apps/admin/src/learning-view-model.ts @@ -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; 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; field_relations?: Array<{ left: string; right: string; operator: string }> }; + }; +} +export function operationName(kind: string) { return ({ fill: '填写参数', click: '点击元素', read: '读取内容' } as Record)[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 }; + }), + }, + }; +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..57b374e --- /dev/null +++ b/apps/admin/src/main.tsx @@ -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(); diff --git a/apps/admin/src/migration.css b/apps/admin/src/migration.css new file mode 100644 index 0000000..8eb83f5 --- /dev/null +++ b/apps/admin/src/migration.css @@ -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; } } diff --git a/apps/admin/src/pages/CandidatesPage.tsx b/apps/admin/src/pages/CandidatesPage.tsx new file mode 100644 index 0000000..53bf077 --- /dev/null +++ b/apps/admin/src/pages/CandidatesPage.tsx @@ -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(); + const [busy, setBusy] = useState(''); + const [verifyOpen, setVerifyOpen] = useState(false); + const [record, setRecord] = useState(); + const [gate, setGate] = useState(); + const [form] = Form.useForm(); + const validations = useResource>('/learning/validations'); + const task = useResource(`/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(`/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(`/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(`/learning/candidates/${candidate.id}/publish-check`)); } + catch (error) { void message.error(errorText(error)); } + finally { setBusy(''); } + } + return
+

{candidate.name}

固定候选版本 v{candidate.version}

+ + + + +
+
状态 创建于 {formatTime(candidate.created_at)}内容摘要 {candidate.digest.slice(0, 16)}
+ }, + { key: 'steps', label: '操作链', children:
{candidate.manifest.steps.length} 步 · 固定顺序
{candidate.manifest.steps.map((step, index) => )}
顺序操作参数 / 输出详情
{index + 1}{operationName(step.kind)}{step.parameter ? `参数 ${step.parameter}` : step.output_name ? `输出 ${step.output_name}` : '固定操作'}
}, + { key: 'evidence', label: '定位与证据', children:

步骤依据

对应学习时的实际观察
{candidate.manifest.steps.map((step, index) => )}
步骤页面定位证据标识
{index + 1}{step.selector}{step.evidence_id || '未关联'}
}, + { key: 'schemas', label: '能力清单', children:

{candidate.digest}

}, + { key: 'tests', label: `测试记录 ${records.length}`, children:
void validations.reload()} />
}, + ]} /> + setSelectedStep(undefined)} width={640} destroyOnHidden>{selectedStep && } + { 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> +

按本次参数重放固定步骤,结果与预期逐项对照并留档。

+ void task.reload()} /> + {task.data?.browser_readiness && !task.data.browser_readiness.ready &&

{task.data.browser_readiness.message}

} +
+ + + +
+
+ setGate(undefined)} destroyOnHidden>{gate && <>

{gate.ready ? '当前预检条件已满足' : '仍有条件需要补齐'}

当前结果为即时预检;满足条件后才能进入后续发布流程。

{gate.checks.map(item => )}
检查项结论说明
{item.name}{item.detail}
}
+ setRecord(undefined)} destroyOnHidden>{record && } +
; +} + +export function CandidatesPage({ selectedId, navigate }: { selectedId?: string; navigate: (path: string) => void }) { + const resource = useResource>('/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 ? : <> void resource.reload()} />; + return

能力实体

{resource.data ? `${all.length} 条候选版本 · 本页 ${items.reduce((sum, item) => sum + item.manifest.steps.length, 0)} 个步骤` : '查看固定版本与操作链'}

+
} value={search} maxLength={200} allowClear placeholder="搜索能力名称或标识" onChange={event => { setSearch(event.target.value); setOffset(0); }} />
+
void resource.reload()} />{resource.data && !resource.loading && !resource.error && <>{items.length ?
{items.map(candidate => )}
能力名称固定版本步骤验证状态内容摘要操作
{candidate.name}v{candidate.version}{candidate.manifest.steps.length}{candidate.digest.slice(0, 12)}
: } +
} +
+
; +} diff --git a/apps/admin/src/pages/LearningCampaignPages.tsx b/apps/admin/src/pages/LearningCampaignPages.tsx new file mode 100644 index 0000000..b127d8b --- /dev/null +++ b/apps/admin/src/pages/LearningCampaignPages.tsx @@ -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>('/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
+

学习批次

{resource.data ? `${all.length} 个批次` : '批次与入口账本'}

+
+ + +
+
+
} value={search} allowClear maxLength={200} aria-label="搜索学习批次" placeholder="按批次名称或目标搜索" onChange={event => { setSearch(event.target.value); setOffset(0); }} /> +
+ {demoBusy &&

正在用真实浏览器保存观察、候选和两个参数样本的验证结果…

} +
void resource.reload()} /> + {resource.data && !resource.loading && !resource.error && <>{items.length === 0 ? : +
{items.map(item => navigate(`/tasks/${item.id}`)}> + + )}
批次来源平台状态根入口更新时间
{item.name}

{item.goal}

{platformLabels[item.platform]}{item.entry_url}{formatTime(item.updated_at || item.created_at)}
} +
+ } +
+ setCreateOpen(false)} onCreated={task => { setCreateOpen(false); navigate(`/tasks/${task.id}`); }} /> +
; +} diff --git a/apps/admin/src/pages/ProgressPage.tsx b/apps/admin/src/pages/ProgressPage.tsx new file mode 100644 index 0000000..8b35e34 --- /dev/null +++ b/apps/admin/src/pages/ProgressPage.tsx @@ -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('/learning/overview'); + const taskResource = useResource>('/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
+

账本总览

{data ? `${data.tasks} 个批次 · ${data.candidates} 个候选 · ${data.validations} 条验证` : '查看持久化的学习与验证记录'}

+ + {data && taskResource.data && !resource.error && !taskResource.error && <> +

各平台学习批次

按实际任务状态统计
{platforms.map(platform => { + const rows = campaigns.filter(item => item.platform === platform); + return ; + })}
来源平台批次总数待开始观察中等待人工登录会话关闭 / 暂停
{platformLabels[platform]}{rows.length}{rows.filter(item => ['draft', 'created'].includes(item.status)).length}{rows.filter(item => ['observing', 'ready'].includes(item.status)).length}{rows.filter(item => ['awaiting_user', 'waiting_login'].includes(item.status)).length}{rows.filter(item => ['closed', 'paused'].includes(item.status)).length}
+

学习入口

setNote(event.target.value)} maxLength={500} />
+ + {observation ?
}> +
{observation.title || '当前页面'}{observation.url}{formatTime(observation.created_at)}{observation.note ? ` · ${observation.note}` : ''}
+ {observation.screenshot_url &&
{`页面观察
} +
{observation.text || '本次观察没有可记录的页面文字'}
+
{selected ? '已选择定位' : '选择页面元素'}}> +
({ value: key, label: `${key} = ${String(task.parameters[key])}` }))} />{!parameter && setValue(event.target.value)} autoComplete="off" placeholder="普通搜索或查询内容" />}} + {kind === 'read' && setOutputName(event.target.value)} placeholder="例如 results 或 comments" />} + +
定位来自最新观察,执行前重新核对。首版只读链路;登录表单与发送、关注等动作不进入这条验证流程。 +
: } + {latestElements.length} 个定位候选} styles={{ body: { padding: 0 } }}> 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) =>
{label || row.tag || '页面元素'}
{row.tag} · {row.kind || '可读取'}
}, + { title: '定位', dataIndex: 'selector', render: (selector: string) => {selector} }, + { title: '匹配', key: 'unique', width: 90, render: (_: unknown, row) => row.sensitive ? 已排除 : {row.unique === false ? '多匹配' : '唯一'} }, + { title: '操作', key: 'choose', width: 85, render: (_: unknown, row) => }, + ]} />
+
}, + { key: 'steps', label: `链路步骤 ${steps.length}`, children: 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) => {selector} }, + { 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 ? : '—' }, + ]} /> }, + { key: 'candidates', label: `候选版本 ${task.candidates?.length || 0}`, children: rowKey="id" dataSource={task.candidates || []} pagination={false} locale={{ emptyText: '完成链路操作后,可生成第一个候选版本。' }} columns={[ + { title: '名称', dataIndex: 'name', render: (name: string, candidate) => }, { title: '版本', dataIndex: 'version', width: 80, render: (version: string | number) => `v${version}` }, { title: '状态', dataIndex: 'status', width: 140, render: (status: string) => }, { title: '创建时间', dataIndex: 'created_at', width: 180, render: (value: string) => formatTime(value) }, + ]} /> }, + { key: 'validations', label: `验证记录 ${task.validations?.length || 0}`, children: }, + { key: 'parameters', label: '任务参数', children: 候选固化时保存参数快照。试跑可传入不同参数,原候选内容保持不变。 }, + ]} /> + setReadyOpen(false)} onOk={() => void markReady()} okText="记录人工确认" confirmLoading={busy === 'ready'}> + +
setIdentity(event.target.value)} placeholder="填写公开标识,避免手机号等登录信息" maxLength={120} autoComplete="off" />
+
+ setCandidateOpen(false)} onOk={() => void createCandidate()} okText="生成候选" confirmLoading={busy === 'candidate'}> + 将当前 {steps.length} 个步骤、参数和页面证据保存为新版本,后续修改不覆盖该版本。
setCandidateName(event.target.value)} maxLength={120} />
+
+ setRecord(undefined)} width={800}>{record && } + ; +} diff --git a/apps/admin/src/pages/TasksPage.tsx b/apps/admin/src/pages/TasksPage.tsx new file mode 100644 index 0000000..f26aea2 --- /dev/null +++ b/apps/admin/src/pages/TasksPage.tsx @@ -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 = { + 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('/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 void submit()} okText="创建任务" confirmLoading={busy} width={700} destroyOnHidden> +
+
+ + + + + {platform === 'demo' ? '本机样本用于验证工具链。示例结果不能代替真实平台验收。' : '任务创建后,由你在打开的官方页面自行完成登录。平台模板只预填学习目标,尚未提供已验证链路。'} +
+
; +} diff --git a/apps/admin/src/pages/TestsPage.tsx b/apps/admin/src/pages/TestsPage.tsx new file mode 100644 index 0000000..4d26211 --- /dev/null +++ b/apps/admin/src/pages/TestsPage.tsx @@ -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>('/learning/validations'); + const capabilities = useResource>('/learning/candidates'); + const [offset, setOffset] = useState(0); + const [pickerOpen, setPickerOpen] = useState(false); + const [selectedCandidate, setSelectedCandidate] = useState(); + const [selected, setSelected] = useState(); + 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

脚本测试

{resource.data ? `${all.length} 条实际记录` : '固定版本测试与来源结果核对'}

+
{ setStatus(value); setOffset(0); }} style={{ minWidth: 144 }} options={[{ value: '', label: '全部结论' }, { value: 'passed', label: '通过' }, { value: 'failed', label: '失败' }]} />
+
void resource.reload()} />{resource.data && !resource.loading && !resource.error && <>{items.length ?
{items.map(run => )}
能力 / 样本固定版本验证类型来源结果实测环境创建时间操作
{name(run.candidate_id)}

{run.sample_name || '静态校验'}

{version(run.candidate_id) === undefined ? '—' : `v${version(run.candidate_id)}`}{run.kind === 'static' ? '静态校验' : '浏览器试跑'}{run.kind === 'static' ? '程序校验' : String(run.environment?.os || run.environment?.platform || '未记录')}{formatTime(run.created_at)}
: } +
} +
+ setPickerOpen(false)} width={480} destroyOnHidden> void capabilities.reload()} />
{cards}
') + + @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'演示详情与评论

{safe}采购需求 {item}

本机演示作者,不是真实平台账号。

评论

演示评论甲:正在了解{safe},需要比较规格。
演示评论乙:{safe}适用场景有哪些?
返回搜索') + + @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() diff --git a/apps/server/postgres.sql b/apps/server/postgres.sql new file mode 100644 index 0000000..8fe3037 --- /dev/null +++ b/apps/server/postgres.sql @@ -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$; diff --git a/apps/server/requirements.txt b/apps/server/requirements.txt new file mode 100644 index 0000000..95b3e21 --- /dev/null +++ b/apps/server/requirements.txt @@ -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 diff --git a/apps/server/tests/conftest.py b/apps/server/tests/conftest.py new file mode 100644 index 0000000..78f2417 --- /dev/null +++ b/apps/server/tests/conftest.py @@ -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() diff --git a/apps/server/tests/test_bootstrap.py b/apps/server/tests/test_bootstrap.py new file mode 100644 index 0000000..cc3bf5b --- /dev/null +++ b/apps/server/tests/test_bootstrap.py @@ -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"]) diff --git a/apps/server/tests/test_learning.py b/apps/server/tests/test_learning.py new file mode 100644 index 0000000..f662152 --- /dev/null +++ b/apps/server/tests/test_learning.py @@ -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()) diff --git a/apps/server/tests/test_postgres.py b/apps/server/tests/test_postgres.py new file mode 100644 index 0000000..5369a77 --- /dev/null +++ b/apps/server/tests/test_postgres.py @@ -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) diff --git a/apps/server/worker.py b/apps/server/worker.py new file mode 100644 index 0000000..55c3260 --- /dev/null +++ b/apps/server/worker.py @@ -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) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6ccd7ec --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/docs/Skills.md b/docs/Skills.md new file mode 100644 index 0000000..39a13be --- /dev/null +++ b/docs/Skills.md @@ -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. \ No newline at end of file diff --git a/docs/开发进度.md b/docs/开发进度.md new file mode 100644 index 0000000..18bc03d --- /dev/null +++ b/docs/开发进度.md @@ -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 协作的小红书实际链路学习。 + +提交/推送:正在执行,成功后回填实际代码提交与远端核验结果。 diff --git a/docs/整体方案-v0.2.md b/docs/整体方案-v0.2.md index 7d04f49..8debbb7 100644 --- a/docs/整体方案-v0.2.md +++ b/docs/整体方案-v0.2.md @@ -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。 + +每轮新增优化先更新本节或其所属章节,说明解决的问题、实施顺序和验收依据;相应进度记录引用当前规划。产品目标变化时修订现有条目,避免叠加多个互相冲突的计划文件。 diff --git a/docs/方案图解.html b/docs/方案图解.html new file mode 100644 index 0000000..0f97e32 --- /dev/null +++ b/docs/方案图解.html @@ -0,0 +1,329 @@ + + + + + + + +AI 获客助手 方案图解 + + + +
+
+
ASSISTANT / PRODUCT & ENGINEERING

AI 获客助手 方案图解

从商家配置商品,到发现需求、形成线索与跟进;将平台操作学习为经过验证的能力,再由桌面端重复执行。

+
v0.2 · 设计阶段文字方案 ↗
+
+
桌面Windows + macOS执行Electron + Playwright模型运营后台按用途配置更新2026.10.22
+ +
+
+
+

+
+
100%
+
+
+
+
固定职责或流程可选或后续异常或修订点击节点查看细节 · 窄屏可横向滚动
+
路径演示
+
+

+
+ +
+
来源:AI 获客助手整体方案 v0.2 · 2026 年 10 月 22 日独立离线文件 · 无外部资源依赖
+
+
+ + + diff --git a/infra/postgres/init.sql b/infra/postgres/init.sql new file mode 100644 index 0000000..daec42f --- /dev/null +++ b/infra/postgres/init.sql @@ -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; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d7577ed --- /dev/null +++ b/package.json @@ -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" } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9bc332d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1654 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + playwright: + specifier: 1.58.2 + version: 1.58.2 + + apps/admin: + dependencies: + '@ant-design/icons': + specifier: 6.3.4 + version: 6.3.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + antd: + specifier: 6.6.4 + version: 6.6.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + lucide-react: + specifier: 1.53.0 + version: 1.53.0(@types/react@19.3.0)(react@19.3.0) + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + devDependencies: + '@types/node': + specifier: 22.20.3 + version: 22.20.3 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.1 + version: 6.1.1(vite@8.3.0(@types/node@22.20.3)) + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.3.0 + version: 8.3.0(@types/node@22.20.3) + +packages: + + '@ant-design/colors@8.0.1': + resolution: {integrity: sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==} + + '@ant-design/cssinjs-utils@2.1.2': + resolution: {integrity: sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + '@ant-design/cssinjs@2.1.2': + resolution: {integrity: sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/fast-color@3.0.1': + resolution: {integrity: sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==} + engines: {node: '>=8.x'} + + '@ant-design/icons-svg@4.6.0': + resolution: {integrity: sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==} + + '@ant-design/icons@6.3.4': + resolution: {integrity: sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/react-slick@2.0.0': + resolution: {integrity: sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==} + peerDependencies: + react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@8.0.7': + resolution: {integrity: sha512-k0hagDDq/6w4v+0Sp4wFvIz/yswH5O7Va6aRTnD7N62Tnborp4X+YB+H3XUcWVSR4aE47WPEXiIIZchBs0GmYg==} + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + + '@rc-component/async-validator@6.0.0': + resolution: {integrity: sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==} + engines: {node: '>=14.x'} + + '@rc-component/cascader@1.22.0': + resolution: {integrity: sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/checkbox@2.0.0': + resolution: {integrity: sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/collapse@1.2.0': + resolution: {integrity: sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/color-picker@3.1.1': + resolution: {integrity: sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@2.0.2': + resolution: {integrity: sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/dialog@1.10.0': + resolution: {integrity: sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/drawer@1.4.2': + resolution: {integrity: sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/dropdown@1.0.3': + resolution: {integrity: sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + '@rc-component/form@1.8.6': + resolution: {integrity: sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/image@1.10.0': + resolution: {integrity: sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/input-number@1.6.2': + resolution: {integrity: sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/input@1.3.1': + resolution: {integrity: sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@rc-component/listy@1.2.3': + resolution: {integrity: sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/mentions@1.12.0': + resolution: {integrity: sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/menu@1.5.0': + resolution: {integrity: sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/mini-decimal@1.1.4': + resolution: {integrity: sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==} + engines: {node: '>=8.x'} + + '@rc-component/motion@1.3.3': + resolution: {integrity: sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mutate-observer@2.0.1': + resolution: {integrity: sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/notification@2.0.9': + resolution: {integrity: sha512-jQT5IVSWSHPTNFXQM2YQONPptjiKXZa4A4Kzwh2xkh3MkL31gRzyjb98N3QHBzLxsyGGJK1aNpn9j9aSO/O0dg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/overflow@1.0.1': + resolution: {integrity: sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/pagination@1.4.0': + resolution: {integrity: sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/picker@1.12.2': + resolution: {integrity: sha512-ZB1A+tzTQkgtR4flPrAMvxIAOelIIOOOsp22fU2Nad6PbZcJacnyGJUJqUH7ZS9mwxeUwurf1pLMKXrVsK1tlA==} + engines: {node: '>=12.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + '@rc-component/portal@2.2.1': + resolution: {integrity: sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==} + engines: {node: '>=12.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/progress@1.0.3': + resolution: {integrity: sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@2.0.0': + resolution: {integrity: sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/rate@1.0.1': + resolution: {integrity: sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/resize-observer@1.1.2': + resolution: {integrity: sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/segmented@1.4.0': + resolution: {integrity: sha512-NZlpKGSplOYlmF5ukUF1fCLr0ns5WfjNoqMlMkf4AHP0iu90wyiP7hn2qZKRHEuRlG4CvEEz/lF9qRiYnLTofQ==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@rc-component/select@1.10.1': + resolution: {integrity: sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/slider@1.1.1': + resolution: {integrity: sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/steps@1.2.3': + resolution: {integrity: sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/switch@1.0.3': + resolution: {integrity: sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/table@1.11.1': + resolution: {integrity: sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/tabs@1.13.0': + resolution: {integrity: sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tooltip@1.5.2': + resolution: {integrity: sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/tour@2.4.0': + resolution: {integrity: sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tree-select@1.16.1': + resolution: {integrity: sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/tree@1.4.0': + resolution: {integrity: sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/trigger@3.10.1': + resolution: {integrity: sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/upload@1.1.1': + resolution: {integrity: sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/util@1.14.0': + resolution: {integrity: sha512-qFpkGBCiyN3NNAU+FOnZdcdz7A/rXCg3JdQk+bujUKAExZgRqzf2yy1f4YN63Lwctic5FcK9rgjQ6aWbykqwEw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/virtual-list@1.6.0': + resolution: {integrity: sha512-aHPlTWVtfgDmzb4oJNrqBW1yeEdFeIS28EoJLQ/dGBgZPRlrQ84c2Rk/Il6JxDUV5P7FDtNFD0aXc7EB22kR3Q==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rolldown/binding-android-arm-eabi@1.2.14': + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.14': + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.14': + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.14': + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.14': + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.14': + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.14': + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.14': + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.14': + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.14': + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/node@22.20.3': + resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + antd@6.6.4: + resolution: {integrity: sha512-nKFi2oY+KrsfAkU8/KwgS0MPKpryIPTVIgoLQRGiT/BcCMakBf5+G8MPy90RdHjEwb0/bulNJV1dxLhHm9cg7w==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + is-mobile@5.0.0: + resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==} + + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lucide-react@1.53.0: + resolution: {integrity: sha512-OxJ7elNku+iqVzOolk5caxq2LM8Mck6lhA6uZx3g2PlZ53+FvBnT6PlJxGCukCa6oF5wQwgSatn9cH1b4olWmQ==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-is@19.3.1: + resolution: {integrity: sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==} + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@ant-design/colors@8.0.1': + dependencies: + '@ant-design/fast-color': 3.0.1 + + '@ant-design/cssinjs-utils@2.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@ant-design/cssinjs': 2.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@babel/runtime': 7.29.10 + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@ant-design/cssinjs@2.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + csstype: 3.2.3 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + stylis: 4.4.0 + + '@ant-design/fast-color@3.0.1': {} + + '@ant-design/icons-svg@4.6.0': {} + + '@ant-design/icons@6.3.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@ant-design/colors': 8.0.1 + '@ant-design/icons-svg': 4.6.0 + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@ant-design/react-slick@2.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + clsx: 2.1.1 + json2mq: 0.2.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + throttle-debounce: 5.0.2 + + '@babel/runtime@7.29.10': {} + + '@babel/runtime@8.0.7': {} + + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + + '@oxc-project/types@0.154.0': {} + + '@rc-component/async-validator@6.0.0': + dependencies: + '@babel/runtime': 7.29.10 + + '@rc-component/cascader@1.22.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/select': 1.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/tree': 1.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/checkbox@2.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/collapse@1.2.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/color-picker@3.1.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@ant-design/fast-color': 3.0.1 + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/context@2.0.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/dialog@1.10.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/portal': 2.2.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/drawer@1.4.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/portal': 2.2.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/dropdown@1.0.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/trigger': 3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/form@1.8.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/async-validator': 6.0.0 + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/image@1.10.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/portal': 2.2.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/input-number@1.6.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/mini-decimal': 1.1.4 + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/input@1.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/listy@1.2.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/portal': 2.2.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/virtual-list': 1.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/mentions@1.12.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/input': 1.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/menu': 1.5.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/trigger': 3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/menu@1.5.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/overflow': 1.0.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/trigger': 3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/mini-decimal@1.1.4': + dependencies: + '@babel/runtime': 7.29.10 + + '@rc-component/motion@1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/mutate-observer@2.0.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/notification@2.0.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/overflow@1.0.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/pagination@1.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/picker@1.12.2(dayjs@1.11.23)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/overflow': 1.0.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/trigger': 3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + dayjs: 1.11.23 + + '@rc-component/portal@2.2.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/progress@1.0.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/qrcode@2.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.10 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/rate@1.0.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/resize-observer@1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/segmented@1.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 8.0.7 + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/select@1.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/overflow': 1.0.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/trigger': 3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/virtual-list': 1.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/slider@1.1.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/steps@1.2.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/switch@1.0.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/table@1.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/context': 2.0.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/virtual-list': 1.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/tabs@1.13.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/dropdown': 1.0.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/menu': 1.5.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/tooltip@1.5.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/trigger': 3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/tour@2.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/portal': 2.2.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/trigger': 3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/tree-select@1.16.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/select': 1.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/tree': 1.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/tree@1.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/virtual-list': 1.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/trigger@3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/portal': 2.2.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/upload@1.1.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rc-component/util@1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + is-mobile: 5.0.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-is: 19.3.1 + + '@rc-component/virtual-list@1.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 8.0.7 + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@rolldown/binding-android-arm-eabi@1.2.14': + optional: true + + '@rolldown/binding-android-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-x64@1.2.14': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.14': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.14': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.14': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.14': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.14': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/node@22.20.3': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@22.20.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0(@types/node@22.20.3) + + antd@6.6.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@ant-design/colors': 8.0.1 + '@ant-design/cssinjs': 2.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@ant-design/cssinjs-utils': 2.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@ant-design/fast-color': 3.0.1 + '@ant-design/icons': 6.3.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@ant-design/react-slick': 2.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@babel/runtime': 7.29.10 + '@rc-component/cascader': 1.22.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/checkbox': 2.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/collapse': 1.2.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/color-picker': 3.1.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/dialog': 1.10.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/drawer': 1.4.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/dropdown': 1.0.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/form': 1.8.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/image': 1.10.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/input': 1.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/input-number': 1.6.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/listy': 1.2.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/mentions': 1.12.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/menu': 1.5.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/motion': 1.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/mutate-observer': 2.0.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/notification': 2.0.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/pagination': 1.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/picker': 1.12.2(dayjs@1.11.23)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/progress': 1.0.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/qrcode': 2.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/rate': 1.0.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/resize-observer': 1.1.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/segmented': 1.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/select': 1.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/slider': 1.1.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/steps': 1.2.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/switch': 1.0.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/table': 1.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/tabs': 1.13.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/tooltip': 1.5.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/tour': 2.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/tree': 1.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/tree-select': 1.16.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/trigger': 3.10.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/upload': 1.1.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@rc-component/util': 1.14.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + dayjs: 1.11.23 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + scroll-into-view-if-needed: 3.1.0 + throttle-debounce: 5.0.2 + transitivePeerDependencies: + - date-fns + - luxon + - moment + + clsx@2.1.1: {} + + compute-scroll-into-view@3.1.1: {} + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + is-mobile@5.0.0: {} + + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lucide-react@1.53.0(@types/react@19.3.0)(react@19.3.0): + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + nanoid@3.3.20: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.31: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react-is@19.3.1: {} + + react@19.3.0: {} + + rolldown@1.2.14: + dependencies: + '@oxc-project/types': 0.154.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 + + scheduler@0.28.0: {} + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + source-map-js@1.2.2: {} + + string-convert@0.2.1: {} + + stylis@4.4.0: {} + + throttle-debounce@5.0.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@6.21.0: {} + + vite@8.3.0(@types/node@22.20.3): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.3 + fsevents: 2.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..e1f89bc --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - apps/admin diff --git a/scripts/check-postgres.mjs b/scripts/check-postgres.mjs new file mode 100644 index 0000000..23d55a0 --- /dev/null +++ b/scripts/check-postgres.mjs @@ -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; }); diff --git a/scripts/check_postgres.py b/scripts/check_postgres.py new file mode 100644 index 0000000..e3e5d39 --- /dev/null +++ b/scripts/check_postgres.py @@ -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 diff --git a/scripts/db.mjs b/scripts/db.mjs new file mode 100644 index 0000000..a767795 --- /dev/null +++ b/scripts/db.mjs @@ -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.'); diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..a76f8ec --- /dev/null +++ b/scripts/dev.mjs @@ -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); }); +} diff --git a/scripts/migrate-learning.mjs b/scripts/migrate-learning.mjs new file mode 100644 index 0000000..e1cb5f0 --- /dev/null +++ b/scripts/migrate-learning.mjs @@ -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; }); diff --git a/scripts/migrate_learning.py b/scripts/migrate_learning.py new file mode 100644 index 0000000..12c7fc6 --- /dev/null +++ b/scripts/migrate_learning.py @@ -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() diff --git a/scripts/processes.mjs b/scripts/processes.mjs new file mode 100644 index 0000000..3b6b5c8 --- /dev/null +++ b/scripts/processes.mjs @@ -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 })]); + } +} diff --git a/scripts/serve.py b/scripts/serve.py new file mode 100644 index 0000000..fc151cb --- /dev/null +++ b/scripts/serve.py @@ -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() diff --git a/scripts/server.mjs b/scripts/server.mjs new file mode 100644 index 0000000..f727e61 --- /dev/null +++ b/scripts/server.mjs @@ -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; }); +} diff --git a/scripts/test-learning.mjs b/scripts/test-learning.mjs new file mode 100644 index 0000000..c3659ba --- /dev/null +++ b/scripts/test-learning.mjs @@ -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); +} diff --git a/scripts/test-server.mjs b/scripts/test-server.mjs new file mode 100644 index 0000000..ceb506f --- /dev/null +++ b/scripts/test-server.mjs @@ -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; }); diff --git a/tools/learning/README.md b/tools/learning/README.md new file mode 100644 index 0000000..67a2113 --- /dev/null +++ b/tools/learning/README.md @@ -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 或上次确认视为当前账号自动核验结果。 diff --git a/tools/learning/browser-worker.mjs b/tools/learning/browser-worker.mjs new file mode 100644 index 0000000..d373a1a --- /dev/null +++ b/tools/learning/browser-worker.mjs @@ -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; + }); +} diff --git a/tools/learning/test-browser-worker.mjs b/tools/learning/test-browser-worker.mjs new file mode 100644 index 0000000..d1c0ae3 --- /dev/null +++ b/tools/learning/test-browser-worker.mjs @@ -0,0 +1,255 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import readline from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const TEMP_ROOT = path.join(ROOT, '.tmp'); +const CHANNEL = process.env.ASSISTANT_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : 'chromium'); +const KEEP_ARTIFACTS = process.argv.includes('--keep-artifacts'); +const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +}[char])); + +function browserClient(home) { + const child = spawn(process.execPath, [path.join(ROOT, 'tools/learning/browser-worker.mjs')], { + cwd: ROOT, + env: { + ...process.env, + ASSISTANT_LEARNING_HOME: home, + ASSISTANT_BROWSER_CHANNEL: CHANNEL, + ASSISTANT_BROWSER_HEADLESS: '1', + }, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let sequence = 0; + let stderr = ''; + let exitResult; + let resolveExit; + const exited = new Promise(resolve => { resolveExit = resolve; }); + const requests = new Map(); + const output = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + output.on('line', line => { + let reply; + try { reply = JSON.parse(line); } + catch { + const error = new Error('Worker stdout contains a non-JSON protocol line'); + for (const request of requests.values()) { clearTimeout(request.timer); request.reject(error); } + requests.clear(); + return; + } + const request = requests.get(reply.id); + if (!request) return; + requests.delete(reply.id); + clearTimeout(request.timer); + if (reply.error) { + const error = new Error(reply.error.message); + error.code = reply.error.code; + request.reject(error); + } else request.resolve(reply.result); + }); + child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-12_000); }); + child.on('error', error => { + for (const request of requests.values()) { clearTimeout(request.timer); request.reject(error); } + requests.clear(); + }); + child.on('exit', (code, signal) => { + exitResult = { code, signal }; + for (const request of requests.values()) { + clearTimeout(request.timer); + request.reject(new Error(`Worker exited before replying, code=${code}. ${stderr}`)); + } + requests.clear(); + resolveExit(exitResult); + }); + return { + call(method, params = {}) { + const id = `worker-test-${++sequence}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + requests.delete(id); + reject(new Error(`Worker timed out: ${method}. ${stderr}`)); + }, 100_000); + requests.set(id, { resolve, reject, timer }); + child.stdin.write(JSON.stringify({ id, method, params }) + '\n', error => { + if (!error) return; + clearTimeout(timer); + requests.delete(id); + reject(error); + }); + }); + }, + async close({ timeout = 15_000 } = {}) { + if (exitResult) return exitResult; + if (!child.stdin.writableEnded) child.stdin.end(); + let timer; + try { + return await Promise.race([exited, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Worker did not release browsers after stdin EOF')), timeout); + })]); + } catch (error) { + child.kill('SIGTERM'); + throw error; + } finally { + clearTimeout(timer); + output.close(); + } + }, + }; +} + +async function main() { + await fs.mkdir(TEMP_ROOT, { recursive: true }); + const runRoot = await fs.mkdtemp(path.join(TEMP_ROOT, 'worker-regression-')); + const home = path.join(runRoot, 'learning'); + const results = []; + let succeeded = false; + let slowRequestCount = 0; + let resolveSlowNavigation; + const slowNavigation = new Promise(resolve => { resolveSlowNavigation = resolve; }); + const server = createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname === '/fixture/slow') { + slowRequestCount += 1; + // No response headers: verify is now waiting in its real browser goto, + // whose ordinary timeout is 20 seconds. EOF must interrupt it earlier. + resolveSlowNavigation(); + return; + } + if (url.pathname !== '/fixture/search') { response.writeHead(404).end(); return; } + const keyword = escapeHtml(url.searchParams.get('q') || '初始'); + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.setHeader('Set-Cookie', 'worker-test-session=retained; Max-Age=86400; Path=/; SameSite=Lax'); + response.end(`本机学习工作进程验收 +

材料搜索

+
${keyword} 的搜索结果
${request.headers.cookie?.includes('worker-test-session=retained') ? 'retained' : 'fresh'}
+

13812345678

`); + }); + 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; });