diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b93f5aae..62f0f67f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,152 +1,97 @@ -name: CI +name: ci on: push: - branches: [main] + branches: + - main + - develop + - codex/** pull_request: - branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + workflow_dispatch: jobs: - # ── 前端检查 ───────────────────────────────────────────── - web-lint: - name: Web Lint + frontend: runs-on: ubuntu-latest + name: Frontend (Vite) defaults: run: - working-directory: apps/web + working-directory: frontend steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Setup Node + uses: actions/setup-node@v4 with: - node-version: '22' - cache: 'npm' - cache-dependency-path: apps/web/package-lock.json - - - run: npm ci --legacy-peer-deps - - run: npm run lint - - web-typecheck: - name: Web Type Check - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/web - steps: - - uses: actions/checkout@v4 + node-version: "24" + cache: npm + cache-dependency-path: frontend/package-lock.json - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - cache-dependency-path: apps/web/package-lock.json + - name: Install dependencies + run: npm ci --legacy-peer-deps - - run: npm ci --legacy-peer-deps - - run: npm run type-check + - name: Test + run: npm run test - web-test: - name: Web Test - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/web - steps: - - uses: actions/checkout@v4 + - name: Build + run: npm run build - - uses: actions/setup-node@v4 + - name: Upload frontend dist + uses: actions/upload-artifact@v4 + if: success() with: - node-version: '22' - cache: 'npm' - cache-dependency-path: apps/web/package-lock.json - - - run: npm ci --legacy-peer-deps - - run: npm test -- --coverage + name: frontend-dist + path: frontend/dist - web-build: - name: Web Build + extension: runs-on: ubuntu-latest - needs: [web-lint, web-typecheck] + name: Browser Extension defaults: run: - working-directory: apps/web + working-directory: chrome/extension-src steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Setup Node + uses: actions/setup-node@v4 with: - node-version: '22' - cache: 'npm' - cache-dependency-path: apps/web/package-lock.json + node-version: "20" + cache: npm + cache-dependency-path: chrome/extension-src/package-lock.json - - run: npm ci --legacy-peer-deps - - run: npm run build + - name: Install dependencies + run: npm ci - # ── Python 后端检查 ────────────────────────────────────── - backend-test: - name: Backend Test - runs-on: ubuntu-latest - defaults: - run: - working-directory: backend - steps: - - uses: actions/checkout@v4 + - name: Build + run: npm run build - - uses: actions/setup-python@v5 + - name: Upload extension dist + uses: actions/upload-artifact@v4 + if: success() with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: pyproject.toml - - - run: pip install .[dev] - - run: pytest --cov=backend --cov-fail-under=80 + name: extension-dist + path: chrome/extension-src/dist - backend-lint: - name: Backend Lint + backend: runs-on: ubuntu-latest - defaults: - run: - working-directory: backend + name: Backend Quality steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Setup Python + uses: actions/setup-python@v5 with: - python-version: '3.11' - - - run: pip install ruff - - run: ruff check . + python-version: "3.11" - # ── Docker 构建 ────────────────────────────────────────── - docker-build: - name: Docker Build - runs-on: ubuntu-latest - needs: [web-build, backend-test] - steps: - - uses: actions/checkout@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] - - uses: docker/setup-buildx-action@v3 - - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Backend syntax check + run: python -m compileall backend - - name: Build Web - uses: docker/build-push-action@v5 - with: - context: ./apps/web - push: false - tags: ghcr.io/${{ github.repository }}/web:${{ github.sha }} - - - name: Build API - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile - push: false - tags: ghcr.io/${{ github.repository }}/api:${{ github.sha }} + - name: Unit tests + run: pytest tests/unit -m "not live" --no-cov diff --git a/.gitignore b/.gitignore index 74058732..030abc73 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ env/ htmlcov/ *.coveragerc coverage.xml +.tmp-smoke/ # Database files *.db @@ -37,9 +38,11 @@ coverage.xml # backend/migrations/versions/*.py # Node / frontend +node_modules/ frontend/node_modules/ frontend/dist/ frontend/tsconfig.tsbuildinfo +.nx/ # macOS .DS_Store @@ -55,6 +58,8 @@ frontend/tsconfig.tsbuildinfo # Logs *.log logs/ +.omx/ +.serena/ # Docker volumes / data data/ @@ -70,3 +75,5 @@ chrome/extension-src/store-assets/ # Rust build odp-rs/target/ +.gstack/ +.repowise/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..96edd2bd --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,17 @@ +title = "opencli-admin gitleaks config" + +[extend] +useDefault = true + +[allowlist] +description = "Ignore generated local artifacts that are not part of the repository." +paths = [ + '''(^|/)\.venv/''', + '''(^|/)venv/''', + '''(^|/)env/''', + '''(^|/)\.tmp-smoke/''', + '''(^|/)node_modules/''', + '''(^|/)frontend/node_modules/''', + '''(^|/)frontend/dist/''', + '''(^|/)chrome/extension-src/node_modules/''', +] diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..87834047 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.12.2 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..e94a10b8 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,23 @@ +# OpenCLI Admin Context + +OpenCLI Admin is an operations console for collection work that needs browser session control, scheduled collection, and operator review. + +## Language + +**Collection Operations Console**: The primary operator surface for turning collection work into captured, triaged, owned, stateful, and closed work. It is the product shape that contains Run Inbox, Data Sources, Live Collection View, and Diagnostic Canvas without making any one visualization the whole product. +_Avoid_: Dashboard wall, canvas-first app + +**Collection Operations**: The operator-facing domain for deciding what should be collected, when collection should run, what recently happened, and which actions are currently safe. It groups Data Sources, Collection Plans, Recent Runs, and Node Actions without making a canvas the primary operating surface. +_Avoid_: Source Workflow Workbench, canvas-first operations + +**Diagnostic Canvas**: A secondary view for understanding relationships among collection entities when troubleshooting or explaining system state. It is not the default place to configure routine collection work. +_Avoid_: Main workflow, primary operating surface + +**Live Collection View**: The operator-facing view of an active collection run as it happens, including streamed progress, rendered browser or pipeline state, and run-specific artifacts. It is anchored to a Recent Run, not to the default configuration surface. +_Avoid_: Static task log, canvas-only monitoring + +**Adaptive Run Surface**: The on-demand layout that opens the right Live Collection View panels for the active run type. It should reveal pipeline events, browser or adapter rendering, and artifacts only when they help the operator understand that run. +_Avoid_: Clock shop, always-on dashboard wall + +**Run Inbox**: The operator-facing queue of collection runs that need observation, review, retry, acknowledgement, or dismissal. It treats a run as work to triage and close, not as a passive row in a log table. +_Avoid_: Recent tasks table, static run history diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..76403332 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,161 @@ +# Design + +## Source of truth +- Status: Active +- Last refreshed: 2026-06-25 +- Primary product surfaces: Dashboard, Source Configuration, Run Inbox, Live Collection View, Tasks, Records, Settings. +- Labs surfaces: Topology Workbench is available only behind `VITE_ENABLE_TOPOLOGY_LAB=true`. +- Evidence reviewed: + - `frontend/src/index.css` + - `frontend/tailwind.config.js` + - `frontend/src/components/opencli/OperatorCard.tsx` + - `frontend/src/components/opencli/WorkbenchPanel.tsx` + - `frontend/src/components/opencli/MetricTile.tsx` + - `frontend/src/labs/topology/TopologyPage.tsx` + - `frontend/src/pages/SourcesPage.tsx` + +## Brand +- Personality: Calm, precise, technical, operator-focused, trustworthy. +- Trust signals: Clear status language, visible execution state, stable density, readable IDs, predictable controls. +- Avoid: Decorative gradients, busy dashboard ornament, oversized marketing composition, unclear action labels, color-only state, visual novelty for its own sake. + +## Product goals +- Goals: + - Keep Topology Workbench as an experimental read-only/lab surface until the core collection loop is stable. + - Keep Source Configuration focused on source identity, parameters, schedules, and health. + - Show live collection telemetry only when a run or pipeline needs it. + - Reuse existing local components and proven libraries instead of creating a separate component library. +- Non-goals: + - Do not make the data-source page the primary graph/node workspace. + - Do not copy OpenBB design-system source code; treat it as product reference only. + - Do not replace the existing yUI/dark console base with a new visual theme. +- Success signals: + - Operators can find unhealthy nodes quickly. + - Source setup remains calmer than the topology workspace. + - Primary, danger, warning, success, and informational states are consistent. + +## Personas jobs +- Primary personas: + - Operator: monitors runs, failures, source health. + - Builder: configures sources, schedules, and agent workflows. + - AI operator: invokes node actions through structured conversational payloads. +- User jobs: + - Understand what is running, blocked, missing, ready, or stale. + - Trigger or retry collection safely. + - Watch live collected information without keeping every telemetry pane permanently open. +- Key contexts of use: desktop-first operations console, long-running collection sessions, mixed human/AI control loops. + +## Information architecture +- Primary navigation: Dashboard, Topology Workbench, Data Sources, Tasks, Records, Settings. +- Core routes/screens: + - Topology Workbench: core graph, next nodes, selected details, node actions. + - Source Configuration: source catalog, channel metadata, schedules, and optional diagnostics canvas. + - Run Inbox: recent work, failed work, running work, review queue. + - Live Collection View: streaming run data, logs, records, tokens, and costs. +- Content hierarchy: + - Topology: operational state first, selected node detail second, actions third. + - Sources: configuration first, workflow/diagnostics second. + +## Design principles +- Principle 1: State legibility before visual expression. +- Principle 2: Work should feel direct and low-friction; follow Linear's product discipline more than its surface styling. +- Principle 3: Topology owns node thinking; Sources owns configuration. +- Tradeoffs: + - Dense operational screens are acceptable when they reduce clicks. + - Popovers, drawers, and panes should appear when useful rather than living on screen all the time. + +## Visual language +- Color: + - Keep the existing near-black console base and translucent borders. + - Use blue for focus/link/primary action. + - Use red for danger/error, amber for warning/pending, green for success/healthy, cyan/gold/violet as secondary signals only. + - Do not let the signal palette become a loud theme. +- Typography: + - Use existing UI, code, and telemetry font variables. + - Keep letter spacing 0 except compact telemetry labels already used in the app. +- Spacing/layout rhythm: + - Follow a 4px scale. + - Prefer stable panel dimensions and compact grouping over decorative spacing. +- Shape/radius/elevation: + - Use 6px radius for panels, cards, buttons, and inputs. + - Use borders and subtle surface contrast before shadows. +- Motion: + - Keep transitions short and functional. + - Honor reduced motion. +- Imagery/iconography: + - Use lucide icons when they clarify action or state. + - Do not add decorative abstract imagery to operational screens. + +## Components +- Existing components to reuse: + - `Card`, `PageHeader`, `CommandPalette`, `MetricTile`, `PanelHeader`, `OperatorCard`, `WorkbenchPanel`, `Button`, `Input`, `StatusBadge`, `EmptyState`. +- New/changed components: + - `OperatorCard` should receive semantic tones, not raw color class strings. + - `MetricTile`, `StatusBadge`, `Button`, and `Badge` should keep danger red separate from primary blue. + - `WorkbenchPanel` can structure topology/source work areas without becoming a decorative card system. +- Variants and states: + - Tones: `neutral`, `accent`, `info`, `gold`, `success`, `warning`, `danger`, `violet`. + - Active/focus remains blue. Error/failure remains red. +- Token/component ownership: + - Global base styles live in `frontend/src/index.css`. + - Tailwind color names live in `frontend/tailwind.config.js`. + - OpenCLI reusable workbench components live in `frontend/src/components/opencli/`. + - Route-specific layout stays in page files until repetition proves a component is needed. + +## Accessibility +- Target standard: WCAG AA for body text and controls. +- Keyboard/focus behavior: + - All interactive controls need visible `:focus-visible`. + - Command Palette remains accessible by `Ctrl/Cmd+K`. +- Contrast/readability: + - Avoid low-contrast text for important labels. + - Do not use color alone for status. +- Screen-reader semantics: + - Buttons need action-specific labels. + - Status-only dots need text context. +- Reduced motion sensory considerations: + - Keep the existing `prefers-reduced-motion` rule. + +## Responsive behavior +- Supported breakpoints/devices: Desktop primary; tablet and mobile should remain readable. +- Layout adaptations: + - Work surfaces may stack on smaller viewports. + - Fixed graph/tool panels need minimum heights and overflow behavior. +- Touch/hover differences: + - Hover should enhance, not reveal essential actions. + +## Interaction states +- Loading: Keep surrounding layout stable. +- Empty: Explain the first useful action, not the feature. +- Error: Say what happened and where the user can recover. +- Success: Toasts name the changed object without filler. +- Disabled: Explain through state labels or nearby context. +- Offline/slow network: Keep cached or previous state visible when possible. + +## Content voice +- Tone: Precise, calm, direct. +- Terminology: + - English: Node, Action, Capability, Run, Source, Task, Agent, Pipeline. + - Chinese: 节点, 动作, 能力, 运行, 数据源, 任务, 智能体, 管线. +- Microcopy rules: + - Action labels should include verb and object when space allows. + - Avoid explaining the UI inside the UI. + +## Implementation constraints +- Framework/styling system: React, Vite, Tailwind, shadcn/Radix-style primitives already present. +- Design-token constraints: + - Use signal colors for state semantics, not for broad decoration. + - Do not pass raw color class strings through reusable component props. +- Performance constraints: + - Graph and live-run views should avoid unnecessary remounts during streaming updates. +- Compatibility constraints: + - FlowGram is a reference/bottom-layer direction; keep current topology behavior working while integrating. + - `react-grid-layout` owns adaptive live-run panes. +- Test/screenshot expectations: + - Run frontend tests and build after token/component changes. +- Smoke `/sources`, `/tasks`, and `/labs/topology` with `VITE_ENABLE_TOPOLOGY_LAB=true` when a dev server is available. + +## Open questions +- [ ] How far should Topology Workbench move toward FlowGram-style editing versus observability-first graph inspection? +- [ ] Which live collection pipelines deserve persistent panes versus temporary popovers/drawers? +- [ ] Should the Source Configuration diagnostics canvas be drawer, modal, or secondary route? diff --git a/README.md b/README.md index f67c7a00..4dcb7cbb 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ **现代化的数据采集系统** — 可视化管理多渠道数据采集,接入 [opencli](https://github.com/jackwener/opencli) 驱动国内外主流平台,支持 AI 处理、多节点分布式调度与实时通知推送。 +## v0.4 前端基线 + +- `frontend/` 是唯一生产前端主线:React + Vite。 +- `experiments/next-web/` 只是 Next.js 实验壳,不接入默认 Docker、CI 或导航。 +- 默认 `docker compose up --build` 会构建仓库内的 `frontend/`,不会拉取旧的上游前端镜像。 +- 拓扑画布属于实验能力;设置 `VITE_ENABLE_TOPOLOGY_LAB=true` 后才开放 `/labs/topology`。 + **OpenCLI WebUI** OpenCLI 可视化界面 [opencli-webui](https://github.com/xjh1994/opencli-webui) **仪表盘** @@ -96,6 +103,20 @@ docker compose up -d # 启动中心 + agent-1 ## 快速开始 +### 方式零:前端主线(推荐) + +生产前端只在 `frontend/` 下开发和构建: + +```bash +cd frontend +npm ci --legacy-peer-deps +npm run dev # Vite dev server +npm run test +npm run build +``` + +扩展仍在 `chrome/extension-src/` 下独立构建。GitHub Actions(`.github/workflows/ci.yml`)也按 frontend、extension、backend 三条真实流水线分别验证。 + ### 方式一:原生 Shell 直接复用本地 opencli 和 Chrome,适合开发和个人使用。 @@ -153,7 +174,7 @@ docker compose up -d | API 文档 | http://localhost:8031/docs | | Agent noVNC | http://localhost:3010 | -镜像已发布至 Docker Hub(`xjh1994/opencli-admin-{api,frontend,agent}:0.3.6`),无需本地构建。从源码构建: +默认 Compose 会从仓库内构建前端。后端和 agent 默认仍可使用已发布镜像;如需全部从源码构建: ```bash docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d diff --git a/README_HANDOVER.md b/README_HANDOVER.md index fe5426b2..3dac5659 100644 --- a/README_HANDOVER.md +++ b/README_HANDOVER.md @@ -5,6 +5,13 @@ --- +## 当前 v0.4 基线 + +- `frontend/` 是唯一生产前端主线,使用 React + Vite。 +- `experiments/next-web/` 只是旧 `apps/web` 的 Next.js 实验壳,不参与默认 Docker、CI 或导航。 +- 默认 `docker compose up --build` 会构建仓库内的 `frontend/`。 +- 旧文档中关于 Next.js/Turborepo 的内容只作为历史迁移设想,不能作为当前实现事实。 + ## 🚀 快速开始 ### 5 分钟了解项目 @@ -17,7 +24,7 @@ cd opencli-admin # 2. 查看项目结构 cat .claude-project.md # 项目配置 cat PONYTAIL.md # 开发规范摘要 -ls -la apps/ packages/ docs/ +ls -la frontend/ chrome/ backend/ docs/ # 3. 启动开发环境 docker compose --profile nas up -d @@ -34,7 +41,7 @@ cat docs/SURVEY_superset.md # 技术选型 cat docs/PROJECT_MANAGEMENT.md # 3. 启动前端开发 -cd apps/web && npm run dev +cd frontend && npm ci --legacy-peer-deps && npm run dev # 4. 启动后端开发 cd backend && ./start.sh @@ -48,8 +55,8 @@ cd backend && ./start.sh | 模块 | 状态 | 说明 | |------|------|------| -| Monorepo 结构 | ✅ | Turborepo 配置 | -| Next.js 骨架 | ✅ | App Router | +| Monorepo 结构 | ✅ | npm workspace + Nx wrapper | +| Vite 前端主线 | ✅ | `frontend/` 是生产前端 | | Docker 支持 | ✅ | 多阶段构建 | | 架构文档 | ✅ | ARCHITECTURE.md v0.2.0 | | 调研文档 | ✅ | SURVEY_superset.md | @@ -62,14 +69,14 @@ cd backend && ./start.sh | 模块 | 状态 | 说明 | |------|------|------| -| Turborepo CI/CD | 🔄 | 需要完善 | +| 分离式 CI/CD | 🔄 | frontend / extension / backend 独立流水线 | | ESLint/Prettier | 🔄 | 待配置 | ### 待开发 ⬜ | 模块 | 优先级 | 说明 | |------|--------|------| -| 组件迁移 | 🔴 高 | Vite SPA → Next.js | +| 组件收敛 | 🔴 高 | 拆分 Vite SPA 页面与设计系统 | | Hono API | 🔴 高 | FastAPI → Hono | | 认证系统 | 🔴 高 | Better Auth | | Drizzle ORM | 🟡 中 | SQLAlchemy → Drizzle | @@ -92,18 +99,14 @@ opencli-admin/ └── docker-compose.yml # Docker 部署 ``` -### 目标技术栈 +### 暂停的实验方向 ``` opencli-admin/ -├── apps/ -│ ├── web/ # Next.js 15 + React 19 (新) -│ └── api/ # Hono (新) -├── packages/ -│ ├── shared/ # 共享类型 (新) -│ └── db/ # Drizzle ORM (新) -├── frontend/ # React 18 (待迁移) -├── backend/ # FastAPI (待迁移) +├── experiments/ +│ └── next-web/ # Next.js shell, not production +├── frontend/ # React + Vite production frontend +├── backend/ # FastAPI production backend ├── iii/ # Python 调度 (保留) └── odp-rs/ # Rust 数据面 (保留) ``` @@ -118,8 +121,8 @@ opencli-admin/ ``` opencli-admin/ -├── apps/ -│ └── web/ # 🆕 Next.js App Router +├── experiments/ +│ └── next-web/ # Next.js 实验壳 │ ├── src/ │ │ ├── app/ # App Router │ │ ├── components/ # 组件 @@ -130,7 +133,7 @@ opencli-admin/ │ └── shared/ # 🆕 共享类型 │ ├── src/ │ └── package.json -├── frontend/ # 现有 Vite SPA +├── frontend/ # React + Vite 生产前端 │ └── src/ ├── backend/ # 现有 FastAPI │ ├── api/v1/ # API 路由 @@ -161,7 +164,7 @@ opencli-admin/ │ ├── workflows/ci.yml # CI/CD │ └── ISSUE_TEMPLATE/ # Issue 模板 ├── docker-compose.yml # Docker 配置 -├── turbo.json # Turborepo 配置 +├── nx.json # Nx wrapper 配置 ├── package.json # Workspace root └── PONYTAIL.md # 开发规范摘要 ``` @@ -188,9 +191,9 @@ cd backend pip install -e ".[dev]" uvicorn backend.main:app --reload -# 前端 (Next.js) -cd apps/web -npm install +# 前端 (Vite) +cd frontend +npm ci --legacy-peer-deps npm run dev # 或者用 Docker @@ -201,7 +204,7 @@ docker compose --profile nas up -d | 服务 | 端口 | URL | |------|------|-----| -| Next.js | 3000 | http://localhost:3000 | +| Vite frontend | 8030 | http://localhost:8030 | | FastAPI | 8000 | http://localhost:8000 | | FastAPI Docs | 8000 | http://localhost:8000/docs | | III Engine | 49134 | ws://localhost:49134 | @@ -255,15 +258,15 @@ docker compose --profile nas up -d ### 生产环境 (NAS) ```bash -docker compose --profile nas --profile nextjs up -d +docker compose --profile nas up --build -d ``` ### 独立部署前端 ```bash -cd apps/web -docker build -t opencli-admin-web . -docker run -p 3000:3000 opencli-admin-web +cd frontend +docker build -t opencli-admin-frontend:local . +docker run -p 8030:80 opencli-admin-frontend:local ``` --- diff --git a/TESTING.md b/TESTING.md index e4117707..0c1e630e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -456,3 +456,84 @@ docker logs agent-1 --tail=20 - **COLLECTION_MODE 切换需重启 API**:这是系统级配置,对应用户修改 `.env` 后执行 `docker compose up -d api` 的正常运维操作。bridge/cdp 模式切换则无需重启,通过 `PATCH /mode` 接口实时生效。 - **Docker 测试前需在宿主机启动 Chrome**:agent 镜像默认使用无 Chrome 变体(约 400 MB),Tests 5-8 依赖宿主机 Chrome 通过 `host.docker.internal` 提供浏览器能力。如需完全自包含,在 `.env` 中设置 `INSTALL_CHROME=true` 和 `CHROME_SUFFIX=-chrome`,重启后会拉取 `-chrome` 变体(约 1.2 GB)。 - **切换 agent 容器后需清理旧节点**:手动 `docker stop/rm` 旧 agent 后,旧 endpoint 仍残留在 in-memory pool 中(节点 DB 也未清理)。切换前需通过 `DELETE /api/v1/nodes/{id}` 主动删除旧节点,或重启 API 让新 agent 重新注册后再清理。Tests 9-10 中切换到 `-chrome` 镜像时需先删除旧 `agent-1` 节点。 + +--- + +## Skill 执行回路(CDP 浏览器驱动) + +Skill 执行回路(`backend/skills/page.py` + `backend/skills/perception.py`)通过 Playwright +**连接已在运行的 Chrome**(`connect_over_cdp`)来驱动页面 —— 复用 `browser_pool` 提供的、 +与上面 Tests 1–10 相同的那个 Chrome(用 `--remote-debugging-port=9222` 启动)。它**不会**另起 +一个浏览器,运行时只需要 Playwright **驱动**本身,不需要第二个 Chrome。 + +```bash +# 安装 Playwright 驱动(Windows / win32 开发或 CI 都需要执行一次) +playwright install chromium +``` + +- Playwright 是新的后端依赖(`pyproject.toml` 已加入 `playwright>=1.40.0`)。`playwright install + chromium` 只装**驱动**;执行回路是 `connect_over_cdp` **挂接**到 `browser_pool` 里那个已经运行 + 的 Chrome(沿用其已登录会话),所以运行时不需要额外的浏览器实例。 +- 仅支持本地 + LAN 的 CDP endpoint(ADR-0003 D1);通过 `agent_server` 驱动 NAT 边缘节点是 v2。 +- 依赖真实浏览器的路径走既有的 `live` pytest marker;默认的 `pytest -m "not live"` **不需要浏览器** + —— 感知快照的纯解析逻辑(`project_snapshot`)和页面包装器的 ref 解析都用 mock 覆盖(见 + `tests/skills/test_perception.py`)。 + +--- + +## 技能执行环路 e2e(live marker,Windows) + +`tests/skills/test_execute_loop_live.py` 是唯一一个跑**真实本地 Chrome**的端到端测试:它通过 +CDP 把整条 `perceive → act → extract → done` 环路、以及 headless 写前确认闸门,对着一个**本机静态 +页面**真跑一遍(issue 07)。整个文件标了 `@pytest.mark.live`,所以默认的 `pytest -m "not live"` +(带 `--cov-fail-under=80`)**永远不需要浏览器**。浏览器是真的;只有便宜模型的**动作选择**被脚本 +固定(patch `backend.channels.skill_channel._build_model_call`),避免模型抖动让 live 测试变 flaky。 + +为新机器(win32)从零复现: + +1. 安装 Playwright + 其 Chromium 驱动(每台机器一次性): + + ```bash + uv pip install playwright # 或 pip install playwright(issue 01 起已是后端依赖) + playwright install chromium + ``` + + 说明(PRD §7):执行环路是 `connect_over_cdp` **挂接**到一个已经在跑的 Chrome,所以这里装的 + Chromium 是给 Playwright **驱动**用的,不一定要再开第二个浏览器。 + +2. 用 CDP 调试端口启动一个本地 Chrome(Windows 路径): + + ```powershell + & "C:\Program Files\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 ` + --no-first-run --no-default-browser-check + ``` + +3. 指向它,并**只**跑这个 live 技能测试: + + ```powershell + $env:SKILL_LIVE_CDP_ENDPOINT = "http://127.0.0.1:9222" + uv run pytest -m live tests/skills/test_execute_loop_live.py + ``` + + 测试从 `SKILL_LIVE_CDP_ENDPOINT` 读 endpoint(回退到 `OPENCLI_CDP_ENDPOINT`)。**未设置时**它会 + `pytest.skip(...)` 并给出可操作的提示,而不是 fail。被测页面由测试内嵌的 `ThreadingHTTPServer` + 起在 `127.0.0.1:<随机端口>`,不依赖任何外部站点。 + +4. 默认套件**不含**它(CI / 本地日常都走这条,不需要 Chrome): + + ```powershell + uv run pytest -m "not live" # 带 --cov-fail-under=80;无需浏览器 + ``` + + 确认它确实被默认排除: + + ```powershell + uv run pytest -m "not live" --collect-only -q | Select-String "test_execute_loop_live" # 应无匹配 + ``` + +5. DB 说明:live 测试把一个**临时 SQLite**(默认内存库,`StaticPool` 单连接共享)绑进 + `backend.database.AsyncSessionLocal` 和 runner 的那份拷贝,这样环路通过 `events.emit` 写的 + `TaskRunEvent` 行对测试自己的查询可见(conftest 里那个 per-test 内存 `db_session` 是**另一个**库, + 环路不会写它)。需要事后翻库时,可改设 `DATABASE_URL` 指向一个一次性文件库。`playwright install + chromium` 是每台机器一次性的准备步骤。 diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py index b08323f5..db583a06 100644 --- a/backend/api/v1/__init__.py +++ b/backend/api/v1/__init__.py @@ -5,12 +5,15 @@ from backend.api.v1 import ( agents, browsers, + chat, dashboard, nodes, notifications, providers, records, schedules, + skill_bridge, + skills, sources, system, tasks, @@ -22,12 +25,15 @@ v1_router.include_router(agents.router) v1_router.include_router(browsers.router) +v1_router.include_router(chat.router) v1_router.include_router(nodes.router) v1_router.include_router(providers.router) v1_router.include_router(sources.router) v1_router.include_router(tasks.router) v1_router.include_router(records.router) v1_router.include_router(schedules.router) +v1_router.include_router(skills.router) +v1_router.include_router(skill_bridge.router) v1_router.include_router(webhooks.router) v1_router.include_router(notifications.router) v1_router.include_router(workers.router) diff --git a/backend/api/v1/chat.py b/backend/api/v1/chat.py new file mode 100644 index 00000000..91bd7676 --- /dev/null +++ b/backend/api/v1/chat.py @@ -0,0 +1,438 @@ +"""Agent 对话坞后端端点. + +采集网络 (`/labs/topology`) 的改动入口。用户用自然语言说话, agent (复用已有 +provider/模型网关 + OpenAI tool-calling) 决定调工具: + + - 只读工具 (list_sources) 直接执行, 喂回结果让 agent 继续推理。 + - 写工具 (toggle_source) **不立即落库**, 返回一个 proposal 让前端弹 diff 确认。 + +确认后前端调 /chat/confirm, 这里才走现有 source_service 落库。写前确认是硬底线。 + +v1 薄闭环: 唯一写动作 = 启停 source。验证通后按同模式扩 trigger_task / update_schedule。 +""" + +import json +import logging +import re +from typing import Any, Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.database import get_db +from backend.models.provider import ModelProvider +from backend.schemas.common import ApiResponse +from backend.schemas.schedule import CronScheduleUpdate +from backend.schemas.source import DataSourceUpdate +from backend.services import schedule_service, source_service, task_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/chat", tags=["chat"]) + +MAX_TOOL_STEPS = 5 + +SYSTEM_PROMPT = """你是 opencli-admin「采集网络」控制台的助手。用户在看一张只读的采集拓扑图\ +(采集项目→计划→任务→处理器→记录→通知)。你的职责: 帮用户看懂采集逻辑, 并按用户意图改动后端配置。 + +规则: +- 需要知道有哪些数据源时, 调 list_sources。 +- 用户要启用/停用某个数据源时, 调 toggle_source。这是写操作, 系统不会立即执行, 会先让用户确认。 +- 不要编造数据源 id; 先用 list_sources 拿到真实 id 再 toggle。 +- 用中文简洁回答。""" + + +# ── 工具定义 (OpenAI function-calling schema) ─────────────────────────────── +TOOLS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "list_sources", + "description": "列出所有采集数据源 (返回 id / name / channel_type / enabled)。只读, 立即执行。", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "toggle_source", + "description": "启用或停用一个采集数据源。写操作, 不会立即生效, 会生成待用户确认的改动。", + "parameters": { + "type": "object", + "properties": { + "source_id": {"type": "string", "description": "数据源 id"}, + "enabled": {"type": "boolean", "description": "true=启用, false=停用"}, + }, + "required": ["source_id", "enabled"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_schedules", + "description": "列出所有定时调度计划 (返回 id / name / cron_expression / enabled / source_id)。只读, 立即执行。", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "list_tasks", + "description": "列出最近的采集任务 (返回 id / source_id / status / trigger_type)。只读, 立即执行。", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "trigger_task", + "description": "对某个数据源立即触发一次采集运行。写操作, 需用户确认。source 必须已启用。", + "parameters": { + "type": "object", + "properties": {"source_id": {"type": "string", "description": "数据源 id"}}, + "required": ["source_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "update_schedule", + "description": "修改一个定时调度: 改 cron 表达式或启用/停用。写操作, 需用户确认。", + "parameters": { + "type": "object", + "properties": { + "schedule_id": {"type": "string", "description": "调度 id"}, + "cron_expression": {"type": "string", "description": "5 段 cron 表达式 (可选)"}, + "enabled": {"type": "boolean", "description": "启用/停用 (可选)"}, + }, + "required": ["schedule_id"], + }, + }, + }, +] + +WRITE_TOOLS = {"toggle_source", "trigger_task", "update_schedule"} + + +# ── request / response 模型 ───────────────────────────────────────────────── +class ChatMessage(BaseModel): + role: Literal["user", "assistant"] + content: str + + +class ChatRequest(BaseModel): + messages: list[ChatMessage] + provider_id: Optional[str] = None + # 选中的画布节点上下文 (kind/id/title), 注入给 agent 当指代背景 + context: Optional[dict[str, Any]] = None + + +class Proposal(BaseModel): + tool: str + args: dict[str, Any] + summary: str + diff: str + + +class ChatReply(BaseModel): + type: Literal["message", "proposal"] + content: Optional[str] = None + proposal: Optional[Proposal] = None + + +class ConfirmRequest(BaseModel): + proposal: Proposal + + +# ── provider → AsyncOpenAI client ─────────────────────────────────────────── +async def _pick_provider(db: AsyncSession, provider_id: Optional[str]) -> ModelProvider: + if provider_id: + provider = await db.get(ModelProvider, provider_id) + if not provider or not provider.enabled: + raise HTTPException(status_code=400, detail="指定的模型 provider 不存在或未启用") + return provider + result = await db.execute( + select(ModelProvider).where(ModelProvider.enabled.is_(True)).order_by(ModelProvider.created_at.asc()) + ) + provider = result.scalars().first() + if not provider: + raise HTTPException(status_code=400, detail="没有可用的模型 provider, 先在「模型提供商」里配置一个并启用") + return provider + + +def _build_client(provider: ModelProvider): + try: + from openai import AsyncOpenAI + except ImportError as exc: + raise HTTPException(status_code=500, detail="openai package not installed") from exc + import os + + api_key = provider.api_key or os.environ.get("OPENAI_API_KEY", "") + return AsyncOpenAI(api_key=api_key, base_url=provider.base_url or None) + + +# ── 只读工具执行 ───────────────────────────────────────────────────────────── +async def _run_read_tool(db: AsyncSession, name: str, args: dict[str, Any]) -> Any: + if name == "list_sources": + sources, _ = await source_service.list_sources(db, page=1, limit=100) + return [ + {"id": s.id, "name": s.name, "channel_type": s.channel_type, "enabled": s.enabled} + for s in sources + ] + if name == "list_schedules": + schedules, _ = await schedule_service.list_schedules(db, page=1, limit=100) + return [ + {"id": s.id, "name": s.name, "cron_expression": s.cron_expression, "enabled": s.enabled, "source_id": s.source_id} + for s in schedules + ] + if name == "list_tasks": + tasks, _ = await task_service.list_tasks(db, page=1, limit=30) + return [ + {"id": t.id, "source_id": t.source_id, "status": t.status, "trigger_type": t.trigger_type} + for t in tasks + ] + return {"error": f"unknown read tool: {name}"} + + +async def _build_proposal(db: AsyncSession, name: str, args: dict[str, Any]) -> Proposal: + if name == "toggle_source": + source_id = args.get("source_id", "") + enabled = bool(args.get("enabled")) + source = await source_service.get_source(db, source_id) + if not source: + raise HTTPException(status_code=404, detail=f"数据源 {source_id} 不存在") + verb = "启用" if enabled else "停用" + return Proposal( + tool=name, + args={"source_id": source_id, "enabled": enabled}, + summary=f"{verb}数据源「{source.name}」", + diff=f"{source.name}: enabled {source.enabled} → {enabled}", + ) + if name == "trigger_task": + source_id = args.get("source_id", "") + source = await source_service.get_source(db, source_id) + if not source: + raise HTTPException(status_code=404, detail=f"数据源 {source_id} 不存在") + return Proposal( + tool=name, + args={"source_id": source_id}, + summary=f"立即采集「{source.name}」", + diff=f"触发一次手动采集: {source.name} ({'已启用' if source.enabled else '已停用'})", + ) + if name == "update_schedule": + schedule_id = args.get("schedule_id", "") + schedule = await schedule_service.get_schedule(db, schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail=f"调度 {schedule_id} 不存在") + out_args: dict[str, Any] = {"schedule_id": schedule_id} + changes: list[str] = [] + if args.get("cron_expression") is not None: + new_cron = str(args["cron_expression"]) + if not schedule_service.validate_cron_expression(new_cron): + raise HTTPException(status_code=400, detail=f"非法 cron 表达式: {new_cron}") + out_args["cron_expression"] = new_cron + changes.append(f"cron {schedule.cron_expression} → {new_cron}") + if args.get("enabled") is not None: + out_args["enabled"] = bool(args["enabled"]) + changes.append(f"enabled {schedule.enabled} → {bool(args['enabled'])}") + if not changes: + raise HTTPException(status_code=400, detail="update_schedule 未指定要改的字段 (cron_expression 或 enabled)") + return Proposal( + tool=name, + args=out_args, + summary=f"修改调度「{schedule.name}」", + diff="; ".join(changes), + ) + raise HTTPException(status_code=400, detail=f"unknown write tool: {name}") + + +@router.post("", response_model=ApiResponse[ChatReply]) +async def chat(body: ChatRequest, db: AsyncSession = Depends(get_db)) -> ApiResponse: + provider = await _pick_provider(db, body.provider_id) + client = _build_client(provider) + model = provider.default_model or "gpt-4o-mini" + + system = SYSTEM_PROMPT + if body.context: + system += f"\n\n当前用户选中的画布节点上下文 (JSON): {json.dumps(body.context, ensure_ascii=False)}" + + if _is_xml_tool_model(model): + return await _chat_xml(client, model, system, body, db) + + messages: list[dict[str, Any]] = [{"role": "system", "content": system}] + messages += [{"role": m.role, "content": m.content} for m in body.messages] + + for _step in range(MAX_TOOL_STEPS): + try: + response = await client.chat.completions.create( + model=model, messages=messages, tools=TOOLS, tool_choice="auto" + ) + except Exception as exc: + logger.error("chat llm error | %s", exc) + raise HTTPException(status_code=502, detail=f"模型调用失败: {exc}") from exc + + msg = response.choices[0].message + tool_calls = msg.tool_calls or [] + + if not tool_calls: + return ApiResponse.ok(ChatReply(type="message", content=msg.content or "")) + + # 写工具命中 → 立即返回 proposal (不执行, 不继续推理) + for tc in tool_calls: + if tc.function.name in WRITE_TOOLS: + args = _safe_json(tc.function.arguments) + proposal = await _build_proposal(db, tc.function.name, args) + return ApiResponse.ok(ChatReply(type="proposal", proposal=proposal)) + + # 只读工具 → 执行, 喂回结果, 继续循环 + messages.append( + { + "role": "assistant", + "content": msg.content or "", + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": {"name": tc.function.name, "arguments": tc.function.arguments}, + } + for tc in tool_calls + ], + } + ) + for tc in tool_calls: + result = await _run_read_tool(db, tc.function.name, _safe_json(tc.function.arguments)) + messages.append( + {"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)} + ) + + return ApiResponse.ok(ChatReply(type="message", content="(达到工具调用步数上限, 请换个说法再试)")) + + +@router.post("/confirm", response_model=ApiResponse[dict]) +async def confirm(body: ConfirmRequest, db: AsyncSession = Depends(get_db)) -> ApiResponse: + """Execute a confirmed proposal. Dispatches by tool; each writes via existing services.""" + proposal = body.proposal + args = proposal.args + + if proposal.tool == "toggle_source": + source = await source_service.get_source(db, args.get("source_id", "")) + if not source: + raise HTTPException(status_code=404, detail="数据源不存在") + await source_service.update_source(db, source, DataSourceUpdate(enabled=bool(args.get("enabled")))) + await db.commit() + logger.info("chat confirm | toggle_source %s -> %s", source.id, args.get("enabled")) + return ApiResponse.ok({"applied": True, "tool": proposal.tool, "summary": proposal.summary}) + + if proposal.tool == "trigger_task": + source = await source_service.get_source(db, args.get("source_id", "")) + if not source: + raise HTTPException(status_code=404, detail="数据源不存在") + if not source.enabled: + raise HTTPException(status_code=400, detail="数据源已停用, 无法采集") + task = await task_service.create_task( + db, source_id=source.id, trigger_type="manual", parameters={}, priority=0, agent_id=None + ) + await db.commit() + from backend.executor import get_executor + + result = await get_executor().dispatch_collection(task.id, {}) + logger.info("chat confirm | trigger_task source=%s task=%s", source.id, task.id) + return ApiResponse.ok({"applied": True, "tool": proposal.tool, "task_id": task.id, "summary": proposal.summary}) + + if proposal.tool == "update_schedule": + schedule = await schedule_service.get_schedule(db, args.get("schedule_id", "")) + if not schedule: + raise HTTPException(status_code=404, detail="调度不存在") + fields = {k: args[k] for k in ("cron_expression", "enabled") if k in args} + await schedule_service.update_schedule(db, schedule, CronScheduleUpdate(**fields)) + await db.commit() + logger.info("chat confirm | update_schedule %s %s", schedule.id, fields) + return ApiResponse.ok({"applied": True, "tool": proposal.tool, "summary": proposal.summary}) + + raise HTTPException(status_code=400, detail=f"unknown proposal tool: {proposal.tool}") + + +# ── XML-style tool models (e.g. Qwable-v1: emits XML, not OpenAI tool_calls) ── +# Qwable-v1 (Qwen3.6-35B distill + Claude Fable-5 tool-use) emits custom +# {json} +# XML in the message content instead of OpenAI `tool_calls`. We describe the +# tools in the system prompt as text and parse the XML ourselves. +XML_TOOL_MODELS = ("qwable",) + +# matches both (self-closing) and {json} +_TOOL_USE_RE = re.compile( + r']*?(?:/\s*>|>\s*(\{.*?\}|)\s*)', re.DOTALL +) +_THINK_RE = re.compile(r".*?", re.DOTALL) + +XML_TOOL_TEXT = ( + "\n\n你是采集网络操作 agent。可用工具:\n" + "- list_sources(): 列出所有数据源 (id/name/enabled)。\n" + "- list_schedules(): 列出定时调度 (id/name/cron_expression/enabled)。\n" + "- list_tasks(): 列出最近采集任务 (id/source_id/status)。\n" + "- toggle_source(source_id, enabled): 启用/停用数据源 (写)。\n" + "- trigger_task(source_id): 立即触发一次采集 (写)。\n" + "- update_schedule(schedule_id, cron_expression?, enabled?): 改调度 cron 或启停 (写)。\n" + '需要调用工具时, 严格输出 XML: {json 参数}\n' + "先用 list_* 拿到真实 id 再做写操作。不要用 markdown 代码块。" +) + + +def _is_xml_tool_model(model: str) -> bool: + m = model.lower() + return any(k in m for k in XML_TOOL_MODELS) + + +def _parse_tool_use(content: str) -> list[tuple[str, dict[str, Any]]]: + calls: list[tuple[str, dict[str, Any]]] = [] + for match in _TOOL_USE_RE.finditer(content or ""): + calls.append((match.group(1), _safe_json(match.group(2) or "{}"))) + return calls + + +async def _chat_xml(client: Any, model: str, system: str, body: ChatRequest, db: AsyncSession) -> ApiResponse: + """Tool loop for XML-style models (parse from content, feed results back as text).""" + messages: list[dict[str, Any]] = [{"role": "system", "content": system + XML_TOOL_TEXT}] + messages += [{"role": m.role, "content": m.content} for m in body.messages] + + for _step in range(MAX_TOOL_STEPS): + try: + response = await client.chat.completions.create(model=model, messages=messages, max_tokens=1024) + except Exception as exc: + logger.error("chat(xml) llm error | %s", exc) + raise HTTPException(status_code=502, detail=f"模型调用失败: {exc}") from exc + + content = response.choices[0].message.content or "" + calls = _parse_tool_use(content) + + if not calls: + clean = _THINK_RE.sub("", content).strip() + return ApiResponse.ok(ChatReply(type="message", content=clean or "(无内容)")) + + # write tool hit → return proposal immediately + for name, args in calls: + if name in WRITE_TOOLS: + proposal = await _build_proposal(db, name, args) + return ApiResponse.ok(ChatReply(type="proposal", proposal=proposal)) + + # read tools → execute, feed results back as text, loop + messages.append({"role": "assistant", "content": content}) + for name, args in calls: + result = await _run_read_tool(db, name, args) + messages.append( + {"role": "user", "content": f'{json.dumps(result, ensure_ascii=False)}'} + ) + + return ApiResponse.ok(ChatReply(type="message", content="(达到工具调用步数上限, 请换个说法再试)")) + + +def _safe_json(raw: str) -> dict[str, Any]: + try: + value = json.loads(raw or "{}") + return value if isinstance(value, dict) else {} + except json.JSONDecodeError: + return {} diff --git a/backend/api/v1/skill_bridge.py b/backend/api/v1/skill_bridge.py new file mode 100644 index 00000000..a6abcb5e --- /dev/null +++ b/backend/api/v1/skill_bridge.py @@ -0,0 +1,127 @@ +"""Skill bridge — Universal Studio kernel entry into the skill execute domain. + +The TS kernel (universal-studio repo) runs ``browser.skill.execute`` as a bridged +node; its ``PythonBridge`` transport POSTs here. This endpoint is a thin, +domain-neutral *mapper* around +:meth:`backend.channels.skill_channel.SkillChannel.collect`: it translates the +kernel's wire envelope ⇄ a channel ``collect()`` call and back, and holds no +skill logic of its own (that lives in :mod:`backend.skills` / the channel). + +Wire envelope — keep in lockstep with ``bridges/python`` in the universal-studio +repo (see ``platform/docs/PHASE-1-horizontal-slice.md``):: + + request : { capability, params, inputs: { port: TypedValue } } + response: { ok, outputs: { port: TypedValue }, events: [ ... ], error? } + +``outputs`` carries three typed ports — ``records`` (DataRef), +``trace`` (DataRef), ``self_eval`` (Value) — mapped from +the :class:`ChannelResult`'s ``items`` + ``metadata``. ``events`` is a post-hoc +projection of the journey trace's ``steps`` (the transport awaits the whole run, +then replays them as kernel ``node.progress`` events); streaming is a later +iteration. + +Intentionally a *separate* router from ``skills.py`` (the human-triggered +re-distill / correct leg) and from ``chat.py`` (the agent dock): the bridge is +its own concern and adds no new skill behaviour — only a transport adapter. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter + +from backend.channels.skill_channel import SkillChannel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/skill", tags=["skill-bridge"]) + +# The single node capability this endpoint serves (the kernel routes by it). +SKILL_EXECUTE = "browser.skill.execute" + +# TypeRefs — keep in lockstep with the manifest in universal-studio +# ``apps/skill-bridge-proof`` (the records / trace / self_eval ports). +_RECORDS_T = {"kind": "DataRef", "of": "Record"} +_TRACE_T = {"kind": "DataRef", "of": "JourneyTrace"} +_SELF_EVAL_T = {"kind": "Value", "of": "SelfEval"} + + +def _typed(type_ref: dict[str, str], value: Any) -> dict[str, Any]: + """Wrap a plain value as a kernel TypedValue (the on-the-wire shape).""" + return {"type": type_ref, "value": value} + + +def _events_from_trace(trace: Any) -> list[dict[str, Any]]: + """Project the journey trace's ``steps`` into node.progress event details. + + The transport awaits the full run then replays these, so a post-hoc + projection of ``trace['steps']`` is the faithful per-step signal (the same + step records the spine ``TaskRunEvent``s are built from). Defensive: a + non-dict trace or non-dict step yields no/blank events. + """ + if not isinstance(trace, dict): + return [] + events: list[dict[str, Any]] = [] + for step in trace.get("steps") or []: + if not isinstance(step, dict): + continue + events.append( + { + "index": step.get("index"), + "verb": step.get("verb"), + "target": step.get("target"), + "error": step.get("error"), + } + ) + return events + + +@router.post("/invoke") +async def skill_invoke(body: dict[str, Any]) -> dict[str, Any]: + """Run a skill capability for the kernel; map ChannelResult ⇄ wire envelope. + + Domain-neutral transport adapter — no auth/session of its own (``collect`` + opens its own short-lived sessions). An unknown capability or a failed + ``ChannelResult`` come back as ``{ok: false, error}`` (HTTP 200) so the + transport surfaces a clean node failure rather than a 500 stacktrace. + """ + capability = body.get("capability") + if capability != SKILL_EXECUTE: + return {"ok": False, "error": f"unknown capability: {capability!r}"} + + params = body.get("params") or {} + inputs = body.get("inputs") or {} + + # inputs.task is a TypedValue { type, value }; the task string is its value. + task = "" + task_input = inputs.get("task") if isinstance(inputs, dict) else None + if isinstance(task_input, dict): + task = str(task_input.get("value") or "") + + # params → channel config (skill_md | skill_id | domain+capability, provider, + # auto_confirm, elements, label, …). task + chrome_endpoint → parameters. + config: dict[str, Any] = dict(params) + parameters: dict[str, Any] = {} + if task: + parameters["task"] = task + if params.get("chrome_endpoint"): + parameters["chrome_endpoint"] = params["chrome_endpoint"] + + try: + result = await SkillChannel().collect(config, parameters) + except Exception as exc: # transport must not leak a 500 stacktrace + logger.error("skill bridge | collect raised: %s", exc) + return {"ok": False, "error": f"skill invoke failed: {exc}"} + + if not result.success: + return {"ok": False, "error": result.error or "skill run failed"} + + trace = result.metadata.get("trace") + outputs = { + "records": _typed(_RECORDS_T, result.items), + "trace": _typed(_TRACE_T, trace), + "self_eval": _typed(_SELF_EVAL_T, result.metadata.get("self_eval")), + } + return {"ok": True, "outputs": outputs, "events": _events_from_trace(trace)} diff --git a/backend/api/v1/skills.py b/backend/api/v1/skills.py new file mode 100644 index 00000000..84d68725 --- /dev/null +++ b/backend/api/v1/skills.py @@ -0,0 +1,113 @@ +"""Skills API — the human-triggered **correct** leg (ADR-0003 D7, D8). + +The dock's ``重蒸技能`` action POSTs a failing ``journey_trace_v1`` trace here; +this endpoint re-distills the skill into version *n+1* via +:func:`backend.skills.correction.re_distill` (re-distillation, never a +hand-patch). Per **D8**, re-distill is **human-triggered only** in v1 — there is +no automatic "N fails → re-distill" path anywhere; this router is the sole entry. + +Auth: this router mirrors the other v1 write endpoints (e.g. ``/chat/confirm``), +which take ``Depends(get_db)`` and rely on the same app-level protection — so the +redistill endpoint is no less protected than they are. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.database import get_db +from backend.models.skill import Skill +from backend.schemas.common import ApiResponse, PaginationMeta +from backend.skills import correction + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/skills", tags=["skills"]) + + +def _skill_brief(s: Skill) -> dict[str, Any]: + """Compact skill projection for the dock (no full skill_md body).""" + return { + "id": s.id, + "domain": s.domain, + "capability": s.capability, + "name": s.name, + "version": s.version, + "status": s.status, + "enabled": s.enabled, + "evidence_count": len(s.evidence or []), + } + + +@router.get("", response_model=ApiResponse[list[dict]]) +async def list_skills( + domain: str | None = None, + enabled: bool | None = None, + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + """List distilled skills (compact). Read-only; used by the dock to pick a + skill to re-distill.""" + stmt = select(Skill) + count_stmt = select(Skill) + if domain is not None: + stmt = stmt.where(Skill.domain == domain) + count_stmt = count_stmt.where(Skill.domain == domain) + if enabled is not None: + stmt = stmt.where(Skill.enabled.is_(enabled)) + count_stmt = count_stmt.where(Skill.enabled.is_(enabled)) + + total = len((await db.execute(count_stmt)).scalars().all()) + stmt = stmt.order_by(Skill.updated_at.desc()).offset((page - 1) * limit).limit(limit) + rows = (await db.execute(stmt)).scalars().all() + return ApiResponse.ok( + data=[_skill_brief(s) for s in rows], + meta=PaginationMeta(total=total, page=page, limit=limit, pages=max(1, -(-total // limit))), + ) + + +@router.post("/{skill_id}/redistill", response_model=ApiResponse[dict]) +async def redistill_skill( + skill_id: str, + body: dict[str, Any], + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + """Re-distill a failing skill from its trace → version *n+1* (D7). + + Body: ``{"trace": }`` (or ``{"traces": [...]}``). The + failing trace is fed back through the distiller; ``version`` bumps by 1, + ``evidence`` gains one ``"corrected"`` entry, and ``skill_md`` / ``elements`` + are replaced from the fresh distillation. Returns the new version. + """ + skill = await db.get(Skill, skill_id) + if not skill: + raise HTTPException(status_code=404, detail=f"技能 {skill_id} 不存在") + + traces = body.get("trace") or body.get("traces") + if not traces: + raise HTTPException( + status_code=400, detail="redistill 需要 body.trace (journey_trace_v1)" + ) + + try: + skill = await correction.re_distill(db, skill, traces) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # distiller / provider failure + logger.error("redistill failed | skill=%s err=%s", skill_id, exc) + raise HTTPException(status_code=502, detail=f"重蒸馏失败: {exc}") from exc + + return ApiResponse.ok( + { + "skill_id": skill.id, + "version": skill.version, + "domain": skill.domain, + "capability": skill.capability, + } + ) diff --git a/backend/api/v1/tasks.py b/backend/api/v1/tasks.py index 2d4b87a2..4d9a1592 100644 --- a/backend/api/v1/tasks.py +++ b/backend/api/v1/tasks.py @@ -1,12 +1,15 @@ +import asyncio +import json from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.ext.asyncio import AsyncSession - +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import StreamingResponse from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from backend.database import get_db from backend.models.source import DataSource +from backend.models.task import TaskRun, TaskRunEvent from backend.schemas.common import ApiResponse, PaginationMeta from backend.schemas.task import CollectionTaskRead, TaskRunRead, TaskTriggerRequest from backend.services import source_service, task_service @@ -14,6 +17,23 @@ router = APIRouter(prefix="/tasks", tags=["tasks"]) +def _serialize_run_event(event: TaskRunEvent) -> dict: + return { + "id": event.id, + "run_id": event.run_id, + "level": event.level, + "step": event.step, + "message": event.message, + "detail": event.detail, + "elapsed_ms": event.elapsed_ms, + "created_at": event.created_at.isoformat(), + } + + +def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + @router.get("", response_model=ApiResponse[list[CollectionTaskRead]]) async def list_tasks( source_id: Optional[str] = None, @@ -29,9 +49,9 @@ async def list_tasks( sources = (await db.execute(select(DataSource).where(DataSource.id.in_(source_ids)))).scalars().all() name_map = {s.id: s.name for s in sources} data = [] - for t in tasks: - item = CollectionTaskRead.model_validate(t) - item.source_name = name_map.get(t.source_id) + for task in tasks: + item = CollectionTaskRead.model_validate(task) + item.source_name = name_map.get(task.source_id) data.append(item) return ApiResponse.ok( data=data, @@ -57,10 +77,11 @@ async def trigger_task( priority=body.priority, agent_id=body.agent_id, ) - # Commit before dispatching so the background runner's new session can find the task + # Commit before dispatching so the background runner's new session can find the task. await db.commit() from backend.executor import get_executor + result = await get_executor().dispatch_collection(task.id, body.parameters) return ApiResponse.ok(result) @@ -91,30 +112,74 @@ async def list_task_runs( ) +async def _get_run_for_task(db: AsyncSession, task_id: str, run_id: str) -> TaskRun: + result = await db.execute( + select(TaskRun).where(TaskRun.id == run_id, TaskRun.task_id == task_id) + ) + run = result.scalar_one_or_none() + if not run: + raise HTTPException(status_code=404, detail="Run not found") + return run + + @router.get("/{task_id}/runs/{run_id}/events", response_model=ApiResponse[list[dict]]) async def list_run_events( task_id: str, run_id: str, db: AsyncSession = Depends(get_db), ) -> ApiResponse: - from backend.models.task import TaskRunEvent - from sqlalchemy import select + await _get_run_for_task(db, task_id, run_id) result = await db.execute( select(TaskRunEvent) .where(TaskRunEvent.run_id == run_id) - .order_by(TaskRunEvent.created_at) + .order_by(TaskRunEvent.created_at, TaskRunEvent.id) ) events_list = result.scalars().all() - return ApiResponse.ok([ - { - "id": e.id, - "run_id": e.run_id, - "level": e.level, - "step": e.step, - "message": e.message, - "detail": e.detail, - "elapsed_ms": e.elapsed_ms, - "created_at": e.created_at.isoformat(), - } - for e in events_list - ]) + return ApiResponse.ok([_serialize_run_event(event) for event in events_list]) + + +@router.get("/{task_id}/runs/{run_id}/events/stream") +async def stream_run_events( + task_id: str, + run_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +) -> StreamingResponse: + await _get_run_for_task(db, task_id, run_id) + + async def event_generator(): + seen_ids: set[str] = set() + heartbeat_count = 0 + + while not await request.is_disconnected(): + result = await db.execute( + select(TaskRunEvent) + .where(TaskRunEvent.run_id == run_id) + .order_by(TaskRunEvent.created_at, TaskRunEvent.id) + ) + events = result.scalars().all() + for event in events: + if event.id in seen_ids: + continue + seen_ids.add(event.id) + yield _sse("run_event", _serialize_run_event(event)) + + run = await _get_run_for_task(db, task_id, run_id) + if run.status not in {"pending", "running", "ai_processing", "queued"}: + yield _sse("run_status", {"run_id": run.id, "status": run.status}) + break + + heartbeat_count += 1 + if heartbeat_count % 10 == 0: + yield _sse("heartbeat", {"run_id": run_id}) + await asyncio.sleep(1) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/auth/__init__.py b/backend/auth/__init__.py new file mode 100644 index 00000000..3708c5c7 --- /dev/null +++ b/backend/auth/__init__.py @@ -0,0 +1,10 @@ +"""Credential security: encrypted-at-rest secrets + AuthManager resolution. + +Secrets never live as plaintext in ``channel_config``. They are stored encrypted +in ``source_credentials`` (Fernet) and resolved at runtime into the runner's +``AuthContext`` so channels never touch raw secrets. +""" + +from backend.auth.manager import AuthManager + +__all__ = ["AuthManager"] diff --git a/backend/auth/crypto.py b/backend/auth/crypto.py new file mode 100644 index 00000000..47cee92f --- /dev/null +++ b/backend/auth/crypto.py @@ -0,0 +1,54 @@ +"""Symmetric encryption for credentials at rest (Fernet). + +The master key comes from the env var ``CREDENTIAL_ENCRYPTION_KEY`` (a urlsafe +base64 32-byte Fernet key). The key is read lazily per call, so importing this +module never fails — only ``encrypt`` / ``decrypt`` require it. Generate a key +with :func:`generate_key`. +""" + +from __future__ import annotations + +import os + +from cryptography.fernet import Fernet, InvalidToken + +ENV_KEY = "CREDENTIAL_ENCRYPTION_KEY" + + +class CredentialCryptoError(RuntimeError): + """The encryption key is missing/invalid, or a token could not be decrypted.""" + + +def _fernet() -> Fernet: + key = os.environ.get(ENV_KEY, "").strip() + if not key: + raise CredentialCryptoError( + f"{ENV_KEY} is not set; cannot encrypt/decrypt credentials. Generate one " + "with: python -c \"from cryptography.fernet import Fernet; " + "print(Fernet.generate_key().decode())\"" + ) + try: + return Fernet(key.encode()) + except (ValueError, TypeError) as exc: + raise CredentialCryptoError(f"{ENV_KEY} is not a valid Fernet key: {exc}") from exc + + +def encrypt(plaintext: str) -> str: + return _fernet().encrypt(plaintext.encode()).decode() + + +def decrypt(token: str) -> str: + fernet = _fernet() # key errors surface as CredentialCryptoError, not a token error + try: + return fernet.decrypt(token.encode()).decode() + except (InvalidToken, ValueError, TypeError) as exc: + # InvalidToken = wrong key / bad HMAC; ValueError/TypeError = malformed + # base64 (binascii.Error is a ValueError subclass). + raise CredentialCryptoError( + "credential ciphertext could not be decrypted (wrong key or corrupt)" + ) from exc + + +def generate_key() -> str: + """Ops helper: a fresh Fernet key for ``CREDENTIAL_ENCRYPTION_KEY``.""" + return Fernet.generate_key().decode() diff --git a/backend/auth/manager.py b/backend/auth/manager.py new file mode 100644 index 00000000..15fdd831 --- /dev/null +++ b/backend/auth/manager.py @@ -0,0 +1,94 @@ +"""AuthManager — encrypted credential store + resolution into AuthContext. + +Secrets live encrypted in ``source_credentials`` (never plaintext in +``channel_config``). ``store`` encrypts and upserts; ``resolve`` decrypts to a +``{key_name: value}`` dict; ``resolve_context`` shapes them into the runner's +``AuthContext`` for a channel's declared ``auth_kind``, so channels never touch +raw secrets. +""" + +from __future__ import annotations + +from backend.auth import crypto +from backend.channels.base import AuthContext + + +class AuthManager: + async def store(self, source_id: str, key_name: str, secret: str) -> None: + """Encrypt ``secret`` and upsert it under ``(source_id, key_name)``.""" + from sqlalchemy import select + + from backend.database import AsyncSessionLocal + from backend.models.source_credential import SourceCredential + + ciphertext = crypto.encrypt(secret) + async with AsyncSessionLocal() as session: + row = ( + await session.execute( + select(SourceCredential).where( + SourceCredential.source_id == source_id, + SourceCredential.key_name == key_name, + ) + ) + ).scalar_one_or_none() + if row is not None: + row.ciphertext = ciphertext + else: + session.add( + SourceCredential( + source_id=source_id, key_name=key_name, ciphertext=ciphertext + ) + ) + await session.commit() + + async def resolve(self, source_id: str) -> dict[str, str]: + """Decrypt all stored secrets for a source into ``{key_name: value}``.""" + from sqlalchemy import select + + from backend.database import AsyncSessionLocal + from backend.models.source_credential import SourceCredential + + async with AsyncSessionLocal() as session: + rows = ( + await session.execute( + select(SourceCredential).where( + SourceCredential.source_id == source_id + ) + ) + ).scalars().all() + return {r.key_name: crypto.decrypt(r.ciphertext) for r in rows} + + async def resolve_context(self, source_id: str, auth_kind: str) -> AuthContext: + """Build the runner's ``AuthContext`` from stored credentials. + + ``none`` short-circuits with no DB hit. ``bearer``/``api_key``/``basic`` + decrypt the relevant secrets and pre-build the auth header so the channel + never sees raw values. + """ + if auth_kind == "none": + return AuthContext(kind="none") + + creds = await self.resolve(source_id) + if auth_kind == "bearer": + token = creds.get("token", "") + return AuthContext( + kind="bearer", + token=token, + headers={"Authorization": f"Bearer {token}"} if token else {}, + ) + if auth_kind == "api_key": + key = creds.get("key", "") + return AuthContext( + kind="api_key", + token=key, + headers={"X-API-Key": key} if key else {}, + ) + if auth_kind == "basic": + import base64 + + user = creds.get("username", "") + pw = creds.get("password", "") + encoded = base64.b64encode(f"{user}:{pw}".encode()).decode() + return AuthContext(kind="basic", headers={"Authorization": f"Basic {encoded}"}) + + return AuthContext(kind=auth_kind) diff --git a/backend/channels/api_channel.py b/backend/channels/api_channel.py index 9ee8f98a..495866c9 100644 --- a/backend/channels/api_channel.py +++ b/backend/channels/api_channel.py @@ -1,5 +1,6 @@ """API channel: direct REST/GraphQL API calls.""" +import logging import os import re from typing import Any @@ -9,6 +10,8 @@ from backend.channels.base import AbstractChannel, ChannelResult from backend.channels.registry import register_channel +logger = logging.getLogger(__name__) + _SECRET_RE = re.compile(r"\{\{secret:([A-Z_][A-Z0-9_]*)\}\}") @@ -88,20 +91,38 @@ def _build_auth_headers(self, auth: dict) -> dict[str, str]: if auth_type == "bearer": token_env = auth.get("token_env", "") token = os.environ.get(token_env, auth.get("token", "")) + self._warn_inline(auth, "token", token_env) return {"Authorization": f"Bearer {token}"} if auth_type == "basic": import base64 + raw_pw = auth.get("password", "") user = _resolve_secrets(auth.get("username", "")) - pw = _resolve_secrets(auth.get("password", "")) + pw = _resolve_secrets(raw_pw) + if raw_pw and "{{secret:" not in raw_pw: + self._warn_inline(auth, "password", "") encoded = base64.b64encode(f"{user}:{pw}".encode()).decode() return {"Authorization": f"Basic {encoded}"} if auth_type == "api_key": header_name = auth.get("header", "X-API-Key") key_env = auth.get("key_env", "") key = os.environ.get(key_env, auth.get("key", "")) + self._warn_inline(auth, "key", key_env) return {header_name: key} return {} + @staticmethod + def _warn_inline(auth: dict, field: str, env_key: str) -> None: + """Deprecation: an inline plaintext secret in ``channel_config.auth``. + Prefer env indirection (``_env`` / ``{{secret:ENV}}``) or the + encrypted credential store (``backend.auth.AuthManager``).""" + if auth.get(field) and not (env_key and env_key in os.environ): + logger.warning( + "api channel: inline plaintext '%s' in channel_config.auth is " + "deprecated; use %s_env or the encrypted credential store", + field, + field, + ) + async def validate_config(self, config: dict[str, Any]) -> list[str]: errors: list[str] = [] if not config.get("base_url"): diff --git a/backend/channels/base.py b/backend/channels/base.py index 08a0b724..bed80b2e 100644 --- a/backend/channels/base.py +++ b/backend/channels/base.py @@ -25,10 +25,75 @@ def fail(cls, error: str) -> "ChannelResult": return cls(success=False, error=error) +# ── Thick channel contract (Phase 0) ───────────────────────────────────────── +# A channel should only declare what it can do and implement the source-specific +# "fetch one batch + parse"; the runner owns every cross-cutting concern (auth +# refresh, pagination, rate limiting, cursor persistence). These types are that +# seam. In Phase 0 they are purely additive: the default fetch() bridges to the +# legacy collect(), so the existing channels inherit the contract for free and +# runtime behaviour is unchanged. Later phases migrate channels onto fetch() and +# wire the runner that calls it. + + +@dataclass(frozen=True) +class Capabilities: + """What a channel can do; the runner orchestrates against this declaration.""" + + incremental: bool = False # can resume from a persisted cursor + paginated: bool = False # can fetch page by page + auth_kind: str = "none" # none | api_key | bearer | oauth2 | session + session_affinity: bool = False # must run on the node holding a live session + default_rate: str = "60/min" # token-bucket default the runner applies + + +@dataclass +class AuthContext: + """Resolved, already-refreshed credentials the runner injects. Phase 2 fills + this from an AuthManager + encrypted store; Phase 0 keeps the placeholder so + FetchContext's type is stable and channels never touch raw secrets.""" + + kind: str = "none" + token: str | None = None + headers: dict[str, str] = field(default_factory=dict) + + +@dataclass +class FetchContext: + """Everything the runner feeds a channel to make ONE fetch. The channel reads + these and does only source-specific work — it never refreshes a token, sleeps + for a rate limit, or persists a cursor itself.""" + + config: dict[str, Any] + params: dict[str, Any] + cursor: dict[str, Any] | None = None # persisted "where we left off" (etag / since_id / page_token) + auth: AuthContext | None = None # resolved credentials (Phase 2) + http: Any = None # shared httpx.AsyncClient, rate-limit + retry built in (Phase 1) + log: Any = None # logger injected by the runner + + +@dataclass +class FetchResult: + """One batch out of a channel. The runner persists next_cursor and, when the + channel is paginated and has_more, calls fetch() again with it.""" + + items: list[dict[str, Any]] = field(default_factory=list) + next_cursor: dict[str, Any] | None = None + has_more: bool = False + + +class ChannelFetchError(Exception): + """Raised by the default fetch() adapter when the wrapped collect() failed, so + the runner applies its retry/backoff policy instead of silently dropping.""" + + class AbstractChannel(ABC): """Base class for all data collection channels.""" channel_type: str + #: What this channel can do. Defaults to the most conservative profile + #: (one-shot, no auth, no pagination) so existing channels keep their current + #: behaviour; a channel overrides this with its own Capabilities(...). + capabilities: Capabilities = Capabilities() @abstractmethod async def collect( @@ -48,6 +113,24 @@ async def collect( async def validate_config(self, config: dict[str, Any]) -> list[str]: """Validate config dict; return list of error strings (empty = valid).""" + async def fetch(self, ctx: FetchContext) -> FetchResult: + """Fetch ONE batch under the thick contract. Default adapter: bridge to the + legacy collect() — a channel that only implements collect() gets fetch() + for free (one-shot, no cursor, no pagination). Channels migrate by + overriding this and reading ctx.cursor / ctx.http / ctx.auth directly. The + runner calls fetch(), never collect().""" + result = await self.collect(ctx.config, ctx.params) + if not result.success: + raise ChannelFetchError(result.error or f"{self.channel_type} collect failed") + return FetchResult(items=result.items) + + def identity(self, item: dict[str, Any]) -> str | None: + """Stable source-native id for an item — the dedup key. Default None → the + normalizer falls back to its content hash (current behaviour). A channel + with a native id (RSS entry.id, tweet id) overrides this to fix 'edit two + chars in the title = a new item'.""" + return None + async def health_check(self) -> bool: """Optional health check. Override to implement channel-specific check.""" return True diff --git a/backend/channels/opencli_channel.py b/backend/channels/opencli_channel.py index 68fe03f4..76166df1 100644 --- a/backend/channels/opencli_channel.py +++ b/backend/channels/opencli_channel.py @@ -12,7 +12,7 @@ import yaml -from backend.channels.base import AbstractChannel, ChannelResult +from backend.channels.base import AbstractChannel, Capabilities, ChannelResult from backend.channels.registry import register_channel logger = logging.getLogger(__name__) @@ -317,6 +317,9 @@ class OpenCLIChannel(AbstractChannel): """Collect data by running the opencli CLI tool.""" channel_type = "opencli" + # Drives a real Chrome from the shared pool → must run on the node holding the + # live session; the pipeline resolves a site-keyed browser binding for it. + capabilities = Capabilities(session_affinity=True) async def collect( self, config: dict[str, Any], parameters: dict[str, Any] diff --git a/backend/channels/registry.py b/backend/channels/registry.py index 53459803..50513bcd 100644 --- a/backend/channels/registry.py +++ b/backend/channels/registry.py @@ -33,6 +33,7 @@ def _load_all_channels() -> None: cli_channel, opencli_channel, rss_channel, + skill_channel, web_scraper_channel, ) diff --git a/backend/channels/rss_channel.py b/backend/channels/rss_channel.py index 3c6d5ae5..7e79d45d 100644 --- a/backend/channels/rss_channel.py +++ b/backend/channels/rss_channel.py @@ -5,7 +5,14 @@ import feedparser import httpx -from backend.channels.base import AbstractChannel, ChannelResult +from backend.channels.base import ( + AbstractChannel, + Capabilities, + ChannelFetchError, + ChannelResult, + FetchContext, + FetchResult, +) from backend.channels.registry import register_channel @@ -14,6 +21,9 @@ class RSSChannel(AbstractChannel): """Collect entries from RSS/Atom feeds.""" channel_type = "rss" + capabilities = Capabilities( + incremental=True, paginated=False, auth_kind="none", default_rate="60/min" + ) async def collect( self, config: dict[str, Any], parameters: dict[str, Any] @@ -63,6 +73,58 @@ def _entry_to_dict(self, entry: Any) -> dict[str, Any]: "id": entry.get("id", entry.get("link", "")), } + async def fetch(self, ctx: FetchContext) -> FetchResult: + """Incremental RSS fetch: a conditional GET keyed on the cursor's etag / + last_modified. A 304 means nothing new — return no items and keep the + cursor unchanged. A 200 reparses and advances the cursor to the response's + ETag / Last-Modified. RSS isn't paginated, so ``has_more`` is always False. + + Uses the runner-provided rate-limited client (``ctx.http``) when present, + falling back to a one-shot client so ``fetch()`` also works standalone. + """ + config = ctx.config + feed_url: str = config.get("feed_url", "") + max_entries: int = config.get("max_entries", 50) + timeout: int = config.get("timeout", 30) + cursor = ctx.cursor or {} + + headers = {"User-Agent": "opencli-admin/1.0 (+https://github.com)"} + if cursor.get("etag"): + headers["If-None-Match"] = cursor["etag"] + if cursor.get("last_modified"): + headers["If-Modified-Since"] = cursor["last_modified"] + + if ctx.http is not None: + response = await ctx.http.get(feed_url, headers=headers) + else: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + response = await client.get(feed_url, headers=headers) + + if response.status_code == 304: + # Not Modified — no new entries; preserve the cursor as-is. + return FetchResult(items=[], next_cursor=(cursor or None), has_more=False) + response.raise_for_status() + + parsed = feedparser.parse(response.text) + if parsed.bozo and not parsed.entries: + raise ChannelFetchError( + f"Failed to parse feed: {getattr(parsed, 'bozo_exception', 'unknown error')}" + ) + items = [self._entry_to_dict(entry) for entry in parsed.entries[:max_entries]] + + next_cursor = dict(cursor) + if etag := response.headers.get("ETag"): + next_cursor["etag"] = etag + if last_modified := response.headers.get("Last-Modified"): + next_cursor["last_modified"] = last_modified + + return FetchResult(items=items, next_cursor=(next_cursor or None), has_more=False) + + def identity(self, item: dict[str, Any]) -> str | None: + # RSS entry id (falls back to link in _entry_to_dict): a stable dedup key, + # so editing two chars of a title is the same item, not a new one. + return item.get("id") or None + async def validate_config(self, config: dict[str, Any]) -> list[str]: errors: list[str] = [] if not config.get("feed_url"): diff --git a/backend/channels/skill_channel.py b/backend/channels/skill_channel.py new file mode 100644 index 00000000..61a9dbe4 --- /dev/null +++ b/backend/channels/skill_channel.py @@ -0,0 +1,520 @@ +"""Skill channel — execute a distilled SKILL.md against a real page. + +The closed-loop **execute** leg of the skill subsystem (ADR-0003): read a +distilled SKILL.md card, bind a real browser from the shared pool (same CDP +substrate the opencli channel uses), and let a *cheap* text model drive the page +step by step until it emits ``done`` or hits the step cap. + +This file is the **spine seam** (issue 05): it stays inside the existing +``task → run → pipeline → events → record`` flow without changing +``AbstractChannel.collect``'s signature. The perceive→gate→act loop itself lives +in :mod:`backend.skills.loop` (issue 03); the risk-tiered confirm gate lives in +:mod:`backend.skills.risk` (issue 04); this channel only *drives* them: + + * read ``run_id`` / ``chrome_endpoint`` out of ``parameters`` (the pipeline + injects them — :func:`backend.pipeline.pipeline.run_pipeline` mirrors its + ``opencli`` special-case), + * acquire a CDP endpoint from :mod:`backend.browser_pool` and attach a + Playwright page (issue 01's :class:`backend.skills.page.SkillPage`), + * resolve the cheap-executor ``provider`` and bind a ``model_call`` to it + (reusing the agent dock's OpenAI tool-calling shape), + * run the loop (:func:`backend.skills.loop.run_skill_loop`), emit one + ``TaskRunEvent`` per step via ``events.emit(run_id, ...)``, + * return ``extract`` records as :class:`ChannelResult` items and propagate + ``awaiting_confirm`` in ``ChannelResult.metadata`` (the loop sets it when the + gate blocks a write in headless v1). + +Issue 06 adds the **feedback** leg on top of this seam: after the loop, the +channel assembles a ``journey_trace_v1`` from the loop's own step records + +outcome (:func:`backend.skills.trace.assemble_trace`), computes a +:func:`~backend.skills.trace.self_eval` against the skill's +``terminal_conditions`` / ``milestones``, appends that self-eval to +``skills.evidence`` (when a persisted :class:`~backend.models.skill.Skill` row is +resolvable), and surfaces the trace on ``ChannelResult.metadata['trace']`` (the +``correct`` leg / re-distill lives in :mod:`backend.skills.correction`). + +``skill_id`` / ``(domain, capability)`` → DB resolution for *loading* the +SKILL.md is wired in :func:`_resolve_skill` (a short-lived ``AsyncSessionLocal``, +same pattern as the self-eval write — ``collect()`` holds no injected session). +Still deferred (v2): cross-process pause/resume of an ``awaiting_confirm`` run. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING, Any + +from backend.channels.base import AbstractChannel, Capabilities, ChannelResult +from backend.channels.registry import register_channel +from backend.pipeline import events + +# risk / perception import only stdlib — safe at registry-load time. The loop is +# imported lazily inside collect() because backend.skills.loop imports +# backend.api.v1.chat, and the channel registry is itself loaded *from* chat's +# import chain (registry → skill_channel → skills.loop → api.v1.chat = cycle). +from backend.skills import perception +from backend.skills.risk import AWAITING_CONFIRM, PROPOSED_ACTION +from backend.skills.trace import assemble_trace, outcome_from_loop, self_eval + +if TYPE_CHECKING: # typing only — keep the LoopResult import out of the cycle + from backend.skills.loop import LoopResult + +logger = logging.getLogger(__name__) + +# Step names emitted into TaskRunEvent.step (free-text String(50)). The run-events +# UI / acceptance tests key on these exact strings (PRD §6; ``self_eval`` is +# issue 06). ``awaiting_confirm`` is emitted by the loop itself when the gate +# blocks a write — see backend.skills.loop / backend.skills.risk. +STEP_PERCEIVE = "skill_perceive" +STEP_STEP = "skill_step" +STEP_EXTRACT = "skill_extract" +STEP_DONE = "skill_done" + + +async def _load_skill_fields( + skill_id: str | None, domain: str | None, capability: str | None +) -> dict[str, Any] | None: + """Load a persisted Skill's fields via a short-lived session. + + ``collect()`` holds no injected session, so — exactly like ``_append_self_eval`` + and ``events.emit`` — we open our own ``AsyncSessionLocal()``. Resolves by + ``skill_id`` first, then the unique ``(domain, capability)``. Reads the needed + columns *inside* the session and returns a plain dict so the caller never + touches a detached ORM instance; ``None`` when no row matches. + """ + from sqlalchemy import select + + from backend.database import AsyncSessionLocal + from backend.models.skill import Skill + + async with AsyncSessionLocal() as session: + skill: Skill | None = None + if skill_id: + skill = await session.get(Skill, skill_id) + if skill is None and domain and capability: + res = await session.execute( + select(Skill).where(Skill.domain == domain, Skill.capability == capability) + ) + skill = res.scalars().first() + if skill is None: + return None + return { + "id": skill.id, + "skill_md": skill.skill_md, + "elements": skill.elements, + "domain": skill.domain, + "capability": skill.capability, + "version": skill.version, + "enabled": skill.enabled, + } + + +async def _resolve_skill( + config: dict[str, Any], +) -> tuple[str | None, dict | None, dict[str, Any], str | None]: + """Resolve ``(skill_md, elements, identity, error)`` for a run. + + Inline ``config['skill_md']`` wins (fast path, no DB). Otherwise load the + persisted skill by ``skill_id`` or ``(domain, capability)`` — the + "SkillService" leg (ADR-0003): the channel reads the stored SKILL.md + + structured elements so a distilled skill executes straight from the DB + without re-supplying its body. ``identity`` carries the resolved + ``skill_id`` / ``domain`` / ``capability`` / ``version`` so the trace + + self-eval write back to the right row. + """ + skill_md = config.get("skill_md") + if skill_md: + return skill_md, _resolve_elements(config), {}, None + + skill_id = config.get("skill_id") + domain = config.get("domain") + capability = config.get("capability") + if not skill_id and not (domain and capability): + return None, None, {}, ( + "skill channel requires config['skill_md'], or 'skill_id', or " + "('domain' + 'capability')." + ) + + try: + fields = await _load_skill_fields(skill_id, domain, capability) + except Exception as exc: # DB failure — surface as a clean fail, don't crash collect + logger.error("skill resolve | DB load failed: %s", exc) + return None, None, {}, f"skill resolve failed: {exc}" + + if fields is None: + ident = skill_id or f"{domain}/{capability}" + return None, None, {}, f"skill not found: {ident}" + if fields["enabled"] is False: + return None, None, {}, f"skill {fields['id']} is disabled — enable it before executing." + md = fields["skill_md"] or "" + if not md: + return None, None, {}, f"skill {fields['id']} has empty skill_md." + + elements = ( + fields["elements"] if isinstance(fields["elements"], dict) and fields["elements"] else None + ) + identity = { + "skill_id": fields["id"], + "domain": fields["domain"], + "capability": fields["capability"], + "version": fields["version"], + } + return md, elements, identity, None + + +def _resolve_elements(config: dict[str, Any]) -> dict | None: + """Pull the structured 9-element dict (Skill.elements shape) from config. + + The loop/prompt builder prefer structured ``elements`` (each loop-control + section addressable) and fall back to the raw ``skill_md`` when absent. For + v1 the elements may be supplied inline alongside ``skill_md``; ``None`` means + "use ``skill_md`` verbatim". + """ + elements = config.get("elements") + return elements if isinstance(elements, dict) and elements else None + + +class _PerceivingPage: + """Adapter giving the loop a single ``page`` that can both *perceive* and *act*. + + The loop (issue 03) perceives through ``await page.snapshot()`` and acts + through issue 02's executor (``page.goto/click/type/select/scroll`` etc.). + Issue 01's :class:`~backend.skills.page.SkillPage` provides the raw ops but + no ``snapshot()`` (perception is a separate module). This thin wrapper adds + ``snapshot()`` by delegating to :func:`backend.skills.perception.snapshot` + on the underlying Playwright page, and forwards every other attribute to the + wrapped ``SkillPage`` so the executor's page ops keep working unchanged. + """ + + def __init__(self, skill_page: Any) -> None: + self._sp = skill_page + + async def snapshot(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]: + return await perception.snapshot(self._sp.page, *args, **kwargs) + + def __getattr__(self, name: str) -> Any: + # Forward goto/click/type/select/scroll/inner_text/extract to the SkillPage. + return getattr(self._sp, name) + + +def _build_model_call(provider: dict[str, Any]) -> Any: + """Bind an ``async (messages, *, tools, model, xml) -> reply`` model caller. + + Reuses the agent dock's OpenAI-compatible client shape + (:class:`openai.AsyncOpenAI` + ``chat.completions.create``) so the cheap + executor model (e.g. ``qwen3:4b`` behind an OpenAI-compatible / Ollama + gateway) is driven exactly like ``backend.api.v1.chat`` drives the console + model. ``reply`` is the raw OpenAI chat object the loop already knows how to + normalize (both ``tool_calls`` and the Qwen XML ```` path). + """ + from openai import AsyncOpenAI + + api_key = provider.get("api_key") or "" + base_url = provider.get("base_url") or None + client = AsyncOpenAI(api_key=api_key, base_url=base_url) + + async def model_call( + messages: list[dict[str, Any]], *, tools: Any, model: str, xml: bool + ) -> Any: + kwargs: dict[str, Any] = {"model": model, "messages": messages} + # OpenAI tool path passes the skill verb schema; the XML path describes + # the verbs in the prompt and sends no tools (mirrors chat._chat_xml). + if not xml and tools is not None: + kwargs["tools"] = tools + kwargs["tool_choice"] = "auto" + return await client.chat.completions.create(**kwargs) + + return model_call + + +def _emit_loop_events(run_id: str, result: LoopResult) -> list[Any]: + """Build the per-step ``events.emit`` coroutines for a finished loop. + + The loop is *pure of the spine* (it only self-emits ``awaiting_confirm`` on a + gate block); spine event emission is this channel's job. We walk the ordered + ``result.steps`` and emit one event each — ``skill_extract`` for ``extract`` + verbs, ``skill_step`` for everything else — bracketed by a leading + ``skill_perceive`` and a trailing ``skill_done`` carrying the outcome. Every + ``emit`` is best-effort and never raises (see ``events.emit``). + """ + coros: list[Any] = [] + coros.append( + events.emit( + run_id, STEP_PERCEIVE, + f"开始执行技能 | 步数={len(result.steps)}", + detail={"step_count": len(result.steps)}, + ) + ) + for step in result.steps: + verb = step.verb or "?" + is_extract = verb == "extract" + coros.append( + events.emit( + run_id, + STEP_EXTRACT if is_extract else STEP_STEP, + f"步骤 {step.index} | {verb}" + (f" | 错误: {step.error}" if step.error else ""), + level="warning" if step.error else "info", + detail={ + "index": step.index, + "verb": step.verb, + "target": step.target, + "error": step.error, + "result": step.result, + }, + elapsed_ms=step.elapsed_ms, + ) + ) + coros.append( + events.emit( + run_id, STEP_DONE, + f"技能执行结束 | 结果={result.outcome} 提取={len(result.extracts)}", + level="warning" if result.outcome in ("error", "done_failed") else "info", + detail={ + "outcome": result.outcome, + "extract_count": len(result.extracts), + "awaiting_confirm": result.awaiting_confirm, + "summary": result.summary, + }, + ) + ) + return coros + + +def _extracts_to_items(result: LoopResult) -> list[dict[str, Any]]: + """Turn the loop's ``extract`` payloads into dict-shaped collected records. + + Each ``extract{data}`` payload is already a free-form dict the model read off + the page. We pass it straight through (a shallow copy) so the normal + ``normalizer.normalize_items`` → ``storer.store_records`` path stores + dedups + it like any other channel's record — no skill-specific store branch. The + normalizer keys off ``title``/``url``/``content`` aliases and falls back to a + full-payload hash when none are present, so an arbitrary extract still stores + and dedups deterministically. + """ + items: list[dict[str, Any]] = [] + for payload in result.extracts: + items.append(dict(payload) if isinstance(payload, dict) else {"value": payload}) + return items + + +def _step_records(result: LoopResult) -> list[dict[str, Any]]: + """Build the trace ``steps[]`` from the loop's *own* ordered step records. + + One dict per loop step (issue 06 acceptance #1). We read ``result.steps`` + directly — the loop accumulated them in memory — rather than re-querying the + best-effort ``TaskRunEvent`` rows ``events.emit`` writes (``collect()`` has no + DB session, and emit is fire-and-forget). Each :class:`StepRecord` already + carries verb / target / snapshot digest / result / timing. + """ + return [s.to_dict() for s in result.steps] + + +def _milestones_hit(result: LoopResult, elements: dict | None) -> list[Any]: + """Best-effort: which declared milestones the run plausibly reached. + + The cheap loop does not emit structured milestone signals, so we use a + conservative NL-tolerant heuristic mirroring ``loop._check_done``: a declared + milestone counts as hit when its phrase appears in any step's note/detail or + the final summary. Empty when none declared. This is a *signal*, not a gate. + """ + declared = (elements or {}).get("milestones") or [] + if not declared: + return [] + haystack_parts: list[str] = [str(result.summary)] + for step in result.steps: + haystack_parts.append(str(step.result)) + if step.error: + haystack_parts.append(str(step.error)) + haystack = " ".join(haystack_parts).lower() + return [m for m in declared if str(m).strip() and str(m).strip().lower() in haystack] + + +def _terminal_check(result: LoopResult) -> Any: + """The loop's terminal verdict for the trace ``outcome`` block. + + On a ``done`` step the loop records ``terminal_check`` (``accepted`` / + ``rejected``); we surface the last such verdict. ``True`` for a clean + ``done_success`` with no recorded check, else ``None``. + """ + for step in reversed(result.steps): + if step.verb == "done" and step.terminal_check is not None: + return step.terminal_check + return True if result.outcome == "done_success" else None + + +async def _append_self_eval( + config: dict[str, Any], ev: dict[str, Any] +) -> bool: + """Append a self-eval entry to the resolvable Skill's ``evidence`` (D7). + + Opens a short-lived ``AsyncSessionLocal()`` (same pattern as ``events.emit`` — + ``collect()`` holds no session), loads the :class:`Skill` by ``skill_id`` or + ``(domain, capability)``, appends ``ev`` to its ``evidence`` list (reassigning + the attribute so SQLAlchemy detects the JSON mutation), and commits. Returns + ``True`` if it wrote, ``False`` for the inline-skill case (no persisted row) — + best-effort, never raises (mirrors ``events.emit``). + """ + skill_id = config.get("skill_id") + domain = config.get("domain") + capability = config.get("capability") + if not skill_id and not (domain and capability): + return False + try: + from sqlalchemy import select + + from backend.database import AsyncSessionLocal + from backend.models.skill import Skill + + async with AsyncSessionLocal() as session: + skill: Skill | None = None + if skill_id: + skill = await session.get(Skill, skill_id) + if skill is None and domain and capability: + res = await session.execute( + select(Skill).where( + Skill.domain == domain, Skill.capability == capability + ) + ) + skill = res.scalars().first() + if skill is None: + return False + evidence = list(skill.evidence or []) + evidence.append(ev) + skill.evidence = evidence # reassign → JSON change-tracking + await session.commit() + return True + except Exception as exc: # best-effort, like events.emit + logger.warning("skill self-eval evidence write failed: %s", exc) + return False + + +@register_channel +class SkillChannel(AbstractChannel): + """Execute a distilled browser skill via a cheap model over CDP.""" + + channel_type = "skill" + # Drives a real Chrome from the shared pool → must run on the node holding the + # live session; the pipeline resolves a site-keyed browser binding for it. + capabilities = Capabilities(session_affinity=True) + + async def collect( + self, config: dict[str, Any], parameters: dict[str, Any] + ) -> ChannelResult: + skill_md, elements, identity, err = await _resolve_skill(config) + if err: + return ChannelResult.fail(err) + + run_id = parameters.get("run_id") + task = parameters.get("task") or config.get("task") or "" + # Cheap executor model (distinct from the distill model). Shape matches + # backend.skills.distill provider config. + provider = config.get("provider", {}) + model = provider.get("model") or "qwen3:4b" + # Guardrail: writes (clicks/typing/submits) require explicit confirm + # unless the source opts a trusted skill into unattended running. + auto_confirm = bool(config.get("auto_confirm", False)) + + from backend.browser_pool import get_pool + from backend.skills.loop import run_skill_loop # lazy: breaks import cycle + from backend.skills.page import open_skill_page + + pool = get_pool() + endpoint = parameters.get("chrome_endpoint") or None + + try: + async with pool.acquire(endpoint=endpoint) as cdp_endpoint: + mode = pool.get_mode(cdp_endpoint) + logger.info( + "skill channel | task=%r mode=%s cdp=%s model=%s confirm=%s run_id=%s", + task[:80], mode, cdp_endpoint, model, auto_confirm, run_id, + ) + + model_call = _build_model_call(provider) + skill_page = await open_skill_page(cdp_endpoint) + try: + page = _PerceivingPage(skill_page) + # Drive the perceive → gate → act loop (issues 03/04). The loop + # self-emits the awaiting_confirm event (it has run_id); per-step + # spine events are emitted by this channel below. + result = await run_skill_loop( + page=page, + model_call=model_call, + model=model, + skill_md=skill_md, + elements=elements, + task=task or None, + skill=elements or config, + auto_confirm=auto_confirm, + run_id=run_id, + emit=events.emit, + ) + finally: + await skill_page.aclose() + + # Emit per-step events (best-effort; no-op when no run_id). + if run_id: + for coro in _emit_loop_events(run_id, result): + await coro + + items = _extracts_to_items(result) + + # ── Feedback leg (issue 06): journey_trace_v1 + self-eval ────── + # Assemble the shared trace from the loop's own step records + + # outcome, compute the self-eval vs the skill's terminal/milestone + # conditions, and append it to skills.evidence when a persisted + # Skill is resolvable (inline-skill case: best-effort skip). + trace_id = run_id or f"skill-{uuid.uuid4().hex}" + domain = identity.get("domain") or config.get("domain") or "unknown" + label = ( + identity.get("capability") + or config.get("capability") + or config.get("label") + or (task[:80] if task else "") + or "unknown" + ) + outcome = outcome_from_loop( + result.outcome, + milestones_hit=_milestones_hit(result, elements), + terminal_check=_terminal_check(result), + extra={"awaiting_confirm": bool(result.awaiting_confirm)}, + ) + outcome["trace_id"] = trace_id + trace = assemble_trace( + _step_records(result), + outcome, + domain=domain, + label=label, + trace_id=trace_id, + extra={"extract_count": len(result.extracts)}, + ) + # self_eval reads elements off a Skill row or a bare elements dict. + ev = self_eval(outcome, elements or config) + await _append_self_eval({**config, **identity}, ev) + + metadata: dict[str, Any] = { + "channel": "skill", + "chrome_mode": mode, + "executed": True, + "outcome": result.outcome, + "trace": trace, + "self_eval": ev, + AWAITING_CONFIRM: bool(result.awaiting_confirm), + } + if result.awaiting_confirm and result.proposed_action is not None: + metadata[PROPOSED_ACTION] = result.proposed_action + return ChannelResult.ok(items, **metadata) + except Exception as exc: + logger.error("skill channel | browser acquire/exec failed: %s", exc) + return ChannelResult.fail(f"skill channel browser error: {exc}") + + async def validate_config(self, config: dict[str, Any]) -> list[str]: + errors: list[str] = [] + if not config.get("skill_md") and not config.get("skill_id") and not ( + config.get("domain") and config.get("capability") + ): + errors.append( + "skill channel requires 'skill_md', or 'skill_id', or " + "('domain' + 'capability')" + ) + return errors diff --git a/backend/migrations/versions/m3h4i5j6k7l8_add_skills.py b/backend/migrations/versions/m3h4i5j6k7l8_add_skills.py new file mode 100644 index 00000000..cf51e8a5 --- /dev/null +++ b/backend/migrations/versions/m3h4i5j6k7l8_add_skills.py @@ -0,0 +1,41 @@ +"""add skills + +Revision ID: m3h4i5j6k7l8 +Revises: l2g3h4i5j6k7 +Create Date: 2026-06-30 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'm3h4i5j6k7l8' +down_revision = 'l2g3h4i5j6k7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'skills', + sa.Column('id', sa.String(36), nullable=False), + sa.Column('domain', sa.String(100), nullable=False), + sa.Column('capability', sa.String(255), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('scope', sa.Text(), nullable=True), + sa.Column('skill_md', sa.Text(), nullable=False, server_default=''), + sa.Column('elements', sa.JSON(), nullable=False), + sa.Column('source_trace', sa.String(255), nullable=True), + sa.Column('distill_model', sa.String(255), nullable=True), + sa.Column('evidence', sa.JSON(), nullable=False), + sa.Column('status', sa.String(50), nullable=False, server_default='draft'), + sa.Column('version', sa.Integer(), nullable=False, server_default='1'), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('domain', 'capability', name='uq_skill_domain_capability'), + ) + + +def downgrade() -> None: + op.drop_table('skills') diff --git a/backend/migrations/versions/n4i5j6k7l8m9_add_awaiting_confirm_run_status.py b/backend/migrations/versions/n4i5j6k7l8m9_add_awaiting_confirm_run_status.py new file mode 100644 index 00000000..fbda930a --- /dev/null +++ b/backend/migrations/versions/n4i5j6k7l8m9_add_awaiting_confirm_run_status.py @@ -0,0 +1,38 @@ +"""add awaiting_confirm run status (anchor for the skill confirm gate) + +Revision ID: n4i5j6k7l8m9 +Revises: m3h4i5j6k7l8 +Create Date: 2026-06-30 + +Anchor migration for the skill execute-loop risk-tiered confirm gate (issue 04 / +ADR-0003 D4/D5/D8, PRD §5). It introduces the ``awaiting_confirm`` ``TaskRun`` +status: a headless run that reaches a confirm-required action (a write matching +the skill's ``red_lines`` or the ``submit|pay|post|delete`` pattern, without +``auto_confirm``) aborts at this status instead of completing. + +``TaskRun.status`` is free-text ``String(50)`` (``backend/models/task.py``), so +storing the new value needs **no DDL** — ``upgrade()`` is a documented no-op. The +migration exists so the feature owns the Alembic head and the new status string is +documented in the chain (the string itself is centralized as +``backend.skills.risk.AWAITING_CONFIRM``). The Phase-4 ``run.status`` write that +reads ``pipeline_result.metadata[AWAITING_CONFIRM]`` is issue 05. +""" +import sqlalchemy as sa # noqa: F401 +from alembic import op # noqa: F401 + +revision = "n4i5j6k7l8m9" +down_revision = "m3h4i5j6k7l8" # current head (m3h4i5j6k7l8_add_skills) +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # No-op: TaskRun.status is free-text String(50); the new 'awaiting_confirm' + # value needs no DDL. This migration is the anchor so the skill confirm-gate + # feature owns the Alembic head and the status string is documented in the + # chain. (PRD §5.) + pass + + +def downgrade() -> None: + pass diff --git a/backend/migrations/versions/o5j6k7l8m9n0_add_write_strategy_to_data_sources.py b/backend/migrations/versions/o5j6k7l8m9n0_add_write_strategy_to_data_sources.py new file mode 100644 index 00000000..7803a589 --- /dev/null +++ b/backend/migrations/versions/o5j6k7l8m9n0_add_write_strategy_to_data_sources.py @@ -0,0 +1,38 @@ +"""add write_strategy to data_sources (strangler-fig sink selection) + +Revision ID: o5j6k7l8m9n0 +Revises: n4i5j6k7l8m9 +Create Date: 2026-07-01 + +Adds ``data_sources.write_strategy`` — the per-source state that selects which +write sink the pipeline uses (``backend.pipeline.sinks.strategy.select_sink``): +``legacy | odp_shadow | odp_dual_required | odp_primary | odp_only``. + +``server_default='legacy'`` so every existing row keeps the original behavior +(DB write with its env-gated ODP shadow-forward); the column is non-null. Wrapped +in ``batch_alter_table`` for SQLite, which rewrites the table to add/drop columns. +""" +import sqlalchemy as sa +from alembic import op + +revision = "o5j6k7l8m9n0" +down_revision = "n4i5j6k7l8m9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("data_sources") as batch: + batch.add_column( + sa.Column( + "write_strategy", + sa.String(length=32), + nullable=False, + server_default="legacy", + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("data_sources") as batch: + batch.drop_column("write_strategy") diff --git a/backend/migrations/versions/p6k7l8m9n0o1_add_source_cursors.py b/backend/migrations/versions/p6k7l8m9n0o1_add_source_cursors.py new file mode 100644 index 00000000..601965cd --- /dev/null +++ b/backend/migrations/versions/p6k7l8m9n0o1_add_source_cursors.py @@ -0,0 +1,38 @@ +"""add source_cursors table (per-source incremental cursor) + +Revision ID: p6k7l8m9n0o1 +Revises: o5j6k7l8m9n0 +Create Date: 2026-07-01 + +Backs ``DBCursorStore`` (``backend.pipeline.cursor_store``): one row per source +holding the channel's "where we left off" cursor (etag/last_modified for RSS, +since_id/page_token for others). Unique on ``source_id`` so save is an upsert. +""" +import sqlalchemy as sa +from alembic import op + +revision = "p6k7l8m9n0o1" +down_revision = "o5j6k7l8m9n0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "source_cursors", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("source_id", sa.String(length=36), nullable=False), + sa.Column("cursor", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("source_id", name="uq_source_cursors_source_id"), + ) + op.create_index( + "ix_source_cursors_source_id", "source_cursors", ["source_id"] + ) + + +def downgrade() -> None: + op.drop_index("ix_source_cursors_source_id", table_name="source_cursors") + op.drop_table("source_cursors") diff --git a/backend/migrations/versions/q7l8m9n0o1p2_add_source_credentials.py b/backend/migrations/versions/q7l8m9n0o1p2_add_source_credentials.py new file mode 100644 index 00000000..aab1f632 --- /dev/null +++ b/backend/migrations/versions/q7l8m9n0o1p2_add_source_credentials.py @@ -0,0 +1,40 @@ +"""add source_credentials table (encrypted per-source secrets) + +Revision ID: q7l8m9n0o1p2 +Revises: p6k7l8m9n0o1 +Create Date: 2026-07-01 + +Backs AuthManager: ciphertext-only credential storage (Fernet), one row per +(source_id, key_name). Plaintext never touches this table. +""" +import sqlalchemy as sa +from alembic import op + +revision = "q7l8m9n0o1p2" +down_revision = "p6k7l8m9n0o1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "source_credentials", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("source_id", sa.String(length=36), nullable=False), + sa.Column("key_name", sa.String(length=64), nullable=False), + sa.Column("ciphertext", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "source_id", "key_name", name="uq_source_credentials_source_key" + ), + ) + op.create_index( + "ix_source_credentials_source_id", "source_credentials", ["source_id"] + ) + + +def downgrade() -> None: + op.drop_index("ix_source_credentials_source_id", table_name="source_credentials") + op.drop_table("source_credentials") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 5fbbe064..445f5fba 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -6,7 +6,10 @@ from backend.models.provider import ModelProvider from backend.models.record import CollectedRecord from backend.models.schedule import CronSchedule +from backend.models.skill import Skill from backend.models.source import DataSource +from backend.models.source_credential import SourceCredential +from backend.models.source_cursor import SourceCursor from backend.models.task import CollectionTask, TaskRun, TaskRunEvent from backend.models.worker import WorkerNode @@ -19,11 +22,14 @@ "EdgeNodeEvent", "ModelProvider", "DataSource", + "SourceCredential", + "SourceCursor", "CollectionTask", "TaskRun", "TaskRunEvent", "CollectedRecord", "CronSchedule", + "Skill", "NotificationRule", "NotificationLog", "WorkerNode", diff --git a/backend/models/skill.py b/backend/models/skill.py new file mode 100644 index 00000000..2a728152 --- /dev/null +++ b/backend/models/skill.py @@ -0,0 +1,44 @@ +from typing import Optional + +from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint +from sqlalchemy import JSON +from sqlalchemy.orm import Mapped, mapped_column + +from backend.models.base import TimestampMixin + + +class Skill(TimestampMixin): + """A reusable browser skill distilled from an execution trace. + + Identified by (domain, capability). `skill_md` is the human/agent-readable + SKILL.md card; `elements` holds the structured 9-element spec (see + backend.skills.distill.ELEMENT_KEYS); `evidence` accumulates closed-loop + events (distilled / executed / corrected with outcomes) that feed the + self-evaluation + correction round. + """ + + __tablename__ = "skills" + __table_args__ = (UniqueConstraint("domain", "capability", name="uq_skill_domain_capability"),) + + # Identity + domain: Mapped[str] = mapped_column(String(100), nullable=False) + capability: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + scope: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # Body + skill_md: Mapped[str] = mapped_column(Text, nullable=False, default="") + # Structured 9-element spec: preconditions, procedure, milestones, + # terminal_conditions, false_terminal_states, recovery_policies, + # anti_drift_boundaries, red_lines. + elements: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + + # Provenance + closed loop + source_trace: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + distill_model: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + evidence: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + + # Lifecycle: draft -> active -> deprecated; version bumps on re-distill. + status: Mapped[str] = mapped_column(String(50), nullable=False, default="draft") + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/backend/models/source.py b/backend/models/source.py index 0bb27f6d..a3603870 100644 --- a/backend/models/source.py +++ b/backend/models/source.py @@ -22,6 +22,14 @@ class DataSource(TimestampMixin): channel_type: Mapped[str] = mapped_column(String(50), nullable=False) channel_config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + # Write destination strategy (strangler-fig): which sink persists collected + # items. legacy | odp_shadow | odp_dual_required | odp_primary | odp_only. + # Default 'legacy' preserves the original DB write (with its env-gated ODP + # shadow-forward). See backend.pipeline.sinks.strategy.select_sink. + write_strategy: Mapped[str] = mapped_column( + String(32), nullable=False, default="legacy", server_default="legacy" + ) + # Optional AI processing config ai_config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) diff --git a/backend/models/source_credential.py b/backend/models/source_credential.py new file mode 100644 index 00000000..f676be9b --- /dev/null +++ b/backend/models/source_credential.py @@ -0,0 +1,21 @@ +from sqlalchemy import String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from backend.models.base import TimestampMixin + + +class SourceCredential(TimestampMixin): + """An encrypted per-source secret (api token, key, password, ...). + + Stores ciphertext only — never plaintext. ``AuthManager`` encrypts on store + and decrypts on resolve. One row per ``(source_id, key_name)``. + """ + + __tablename__ = "source_credentials" + __table_args__ = ( + UniqueConstraint("source_id", "key_name", name="uq_source_credentials_source_key"), + ) + + source_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + key_name: Mapped[str] = mapped_column(String(64), nullable=False) + ciphertext: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/models/source_cursor.py b/backend/models/source_cursor.py new file mode 100644 index 00000000..7a137fd8 --- /dev/null +++ b/backend/models/source_cursor.py @@ -0,0 +1,22 @@ +from sqlalchemy import JSON, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from backend.models.base import TimestampMixin + + +class SourceCursor(TimestampMixin): + """Per-source incremental cursor — the channel's "where we left off". + + One row per source: an etag/last_modified for RSS, a since_id/page_token for + others. ``DBCursorStore`` reads/writes this. The cursor only advances once + collected data has reached a reliable write layer, so a crash or a failed + write re-fetches instead of silently skipping. + """ + + __tablename__ = "source_cursors" + __table_args__ = ( + UniqueConstraint("source_id", name="uq_source_cursors_source_id"), + ) + + source_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + cursor: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) diff --git a/backend/odp/__init__.py b/backend/odp/__init__.py new file mode 100644 index 00000000..38252370 --- /dev/null +++ b/backend/odp/__init__.py @@ -0,0 +1,7 @@ +"""Typed mirror of the Rust ODP contract (``odp-rs/crates/odp-contracts``). + +The forward path to the Rust ingest service exchanges a fixed wire shape. This +package pins that shape in one place (:mod:`backend.odp.schemas`) and the single +mapper that produces it (:mod:`backend.odp.mapper`), so the legacy forwarder and +the future ``OdpSink`` cannot drift apart. +""" diff --git a/backend/odp/mapper.py b/backend/odp/mapper.py new file mode 100644 index 00000000..f9ab7a84 --- /dev/null +++ b/backend/odp/mapper.py @@ -0,0 +1,71 @@ +"""Map a normalized pipeline record to a :class:`RecordEvent`. + +The input is the normalizer's output (the ``normalized`` dict plus its ``raw`` +source item and ``content_hash``), NOT the raw collector item. The ODP payload +must carry the same fields the legacy DB stores in ``normalized_data``, or a +shadow comparison would diff on shape instead of substance. + +This mapper is the single source of truth for the ODP wire shape: both the +legacy forwarder (``odp_client.triple_to_event``) and the future ``OdpSink`` go +through it. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from backend.odp.schemas import IngestMode, RecordEvent + + +def provider_for_channel(channel_type: str) -> str: + return f"opencli-admin/{channel_type}" + + +def _source_ts(normalized: dict[str, Any]) -> str: + """RFC3339 ``source_ts`` from ``normalized['published_at']``. + + Falls back to ``now(UTC)`` when the field is absent or unparseable, and + assumes UTC for a naive timestamp — identical to the legacy forwarder. + """ + published = normalized.get("published_at") or "" + try: + if published: + ts = datetime.fromisoformat(published.replace("Z", "+00:00")) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + else: + ts = datetime.now(timezone.utc) + except ValueError: + ts = datetime.now(timezone.utc) + return ts.isoformat().replace("+00:00", "Z") + + +class RecordEventMapper: + """Normalized record -> :class:`RecordEvent`.""" + + @staticmethod + def from_triple( + *, + channel_type: str, + source_id: str, + task_id: str, + raw: dict[str, Any], + normalized: dict[str, Any], + content_hash: str, + ingest_mode: IngestMode = "snapshot", + cursor: str | None = None, + trace_id: str | None = None, + ) -> RecordEvent: + return RecordEvent( + provider=provider_for_channel(channel_type), + source_id=str(source_id), + event_id=content_hash, + source_ts=_source_ts(normalized), + payload=normalized, + raw_data=raw, + ingest_mode=ingest_mode, + cursor=cursor, + trace_id=trace_id, + task_id=str(task_id), + ) diff --git a/backend/odp/schemas.py b/backend/odp/schemas.py new file mode 100644 index 00000000..ab0dc285 --- /dev/null +++ b/backend/odp/schemas.py @@ -0,0 +1,105 @@ +"""Typed mirror of the Rust ODP contract (``odp-rs/crates/odp-contracts``). + +These dataclasses pin the wire shape opencli-admin exchanges with the ODP ingest +service so the forward path has one testable source of truth. Field names and +semantics track ``RecordEvent`` / ``IngestBatchResponse`` in ``odp-contracts`` +(``record_v2.schema.json``); the integer :data:`SCHEMA_VERSION` is ``1`` — the +"v2" in the file name is the schema revision, not the wire version. + +Wire-form note: :meth:`RecordEvent.to_wire` reproduces the EXACT JSON the legacy +``odp_client.triple_to_event`` forwarder emits today — explicit ``null`` for an +absent ``cursor``/``trace_id``/``task_id``/``raw_data``, ids stringified — NOT +Rust's skip-when-null serialization. The Rust side accepts both via +``#[serde(default)]``; keeping the bytes identical is what lets a later step swap +the forwarder for an ``OdpSink`` behind a characterization test proving the +payload is unchanged. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +# Mirrors ``odp_contracts::SCHEMA_VERSION``. +SCHEMA_VERSION = 1 + +# Mirrors the Rust ``IngestMode`` enum (serde snake_case). +IngestMode = Literal["snapshot", "stream"] + + +@dataclass +class RecordEvent: + """One event on the ODP ingest wire. Mirrors ``odp_contracts::RecordEvent``.""" + + provider: str + source_id: str + event_id: str + source_ts: str # RFC3339, e.g. "2026-06-30T12:00:00Z" + payload: dict[str, Any] + raw_data: Any = None + ingest_mode: IngestMode = "snapshot" + cursor: str | None = None + trace_id: str | None = None + task_id: str | None = None + schema_version: int = SCHEMA_VERSION + + def to_wire(self) -> dict[str, Any]: + """JSON-ready dict, byte-compatible with the legacy forwarder. + + Key order and explicit-null fields match the historical payload exactly; + do not "tidy" this into skip-when-null without re-locking the + characterization tests. + """ + return { + "schema_version": self.schema_version, + "provider": self.provider, + "source_id": self.source_id, + "event_id": self.event_id, + "ingest_mode": self.ingest_mode, + "source_ts": self.source_ts, + "cursor": self.cursor, + "payload": self.payload, + "raw_data": self.raw_data, + "trace_id": self.trace_id, + "task_id": self.task_id, + } + + +@dataclass +class IngestReject: + """One rejected event in a batch response. Mirrors ``odp_contracts::IngestReject``.""" + + index: int + reason: str + event_id: str | None = None + + @classmethod + def from_wire(cls, d: dict[str, Any]) -> "IngestReject": + return cls( + index=int(d.get("index", 0)), + reason=str(d.get("reason", "")), + event_id=d.get("event_id"), + ) + + +@dataclass +class OdpIngestResponse: + """Batch ingest result. Mirrors ``odp_contracts::IngestBatchResponse``. + + The legacy forwarder reads only the three counts; ``errors`` is parsed here + so callers that want per-event reject detail no longer have to re-derive it. + """ + + accepted: int = 0 + duplicates: int = 0 + rejected: int = 0 + errors: list[IngestReject] = field(default_factory=list) + + @classmethod + def from_wire(cls, d: dict[str, Any]) -> "OdpIngestResponse": + return cls( + accepted=int(d.get("accepted", 0)), + duplicates=int(d.get("duplicates", 0)), + rejected=int(d.get("rejected", 0)), + errors=[IngestReject.from_wire(e) for e in (d.get("errors") or [])], + ) diff --git a/backend/pipeline/channel_runner.py b/backend/pipeline/channel_runner.py new file mode 100644 index 00000000..5d2b8caa --- /dev/null +++ b/backend/pipeline/channel_runner.py @@ -0,0 +1,100 @@ +"""run_channel — the thick channel runner (Phase 1). + +The runner owns every cross-cutting concern so a channel implements only the +source-specific ``fetch()``: it loads the cursor, builds a rate-limited + retrying +HTTP client, drives pagination via ``has_more`` / ``next_cursor``, and persists +the cursor after each page (so a crash mid-pagination resumes, not restarts). + +This is the mechanism behind the north star: adding a real source is ~100 lines of +fetch + parse, because token refresh, rate limiting, retry/backoff, pagination, +and cursor state all live here, written once, reused by every channel. + +Phase 1 status: additive and NOT yet wired into the collect stage of the live +pipeline (``backend/pipeline/collector.py`` still calls ``channel.collect``). It +runs against an in-memory cursor store and accepts an injected client/channel for +tests. The DB-backed cursor store, the migration, the RSS etag override, and the +switch of the collect stage from ``collect()`` to ``run_channel()`` are the next +slice. +""" + +import logging +from typing import Any + +import httpx + +from backend.channels.base import AbstractChannel, FetchContext +from backend.channels.registry import get_channel +from backend.pipeline.cursor_store import CursorStore, InMemoryCursorStore +from backend.pipeline.http_client import RateLimitedClient, TokenBucket, parse_rate + +log = logging.getLogger(__name__) + +#: Hard guard on pagination so a misbehaving source can't loop forever. +MAX_PAGES = 50 + + +async def run_channel( + source: Any, + params: dict[str, Any], + *, + cursor_store: CursorStore | None = None, + channel: AbstractChannel | None = None, + http: Any = None, +) -> list[dict[str, Any]]: + """Collect all items for ``source`` through its channel's ``fetch()``, applying + the runner's cross-cutting concerns. + + ``source`` needs ``id``, ``channel_type``, ``channel_config``. ``channel`` and + ``http`` are injectable for tests; in production they default to the registry + channel and a rate-limited client built from the channel's declared rate. + """ + chan = channel or get_channel(source.channel_type) + cap = chan.capabilities + store = cursor_store or InMemoryCursorStore() + + cursor = await store.load(source.id) if cap.incremental else None + # Phase 2: resolve real (decrypted) credentials into the AuthContext the channel + # sees. auth_kind="none" short-circuits without a DB hit. + from backend.auth.manager import AuthManager + + auth = await AuthManager().resolve_context(source.id, cap.auth_kind) + + owns_http = http is None + client = http or RateLimitedClient( + httpx.AsyncClient(timeout=30), + TokenBucket(parse_rate(cap.default_rate)), + log=log, + ) + + items: list[dict[str, Any]] = [] + pages = 0 + try: + while True: + ctx = FetchContext( + config=source.channel_config, + params=params, + cursor=cursor, + auth=auth, + http=client, + log=log, + ) + result = await chan.fetch(ctx) + items.extend(result.items) + + # Persist the cursor after each page: a crash mid-pagination resumes + # from here instead of re-fetching everything. + if cap.incremental and result.next_cursor is not None: + cursor = result.next_cursor + await store.save(source.id, cursor) + + pages += 1 + if not (cap.paginated and result.has_more): + break + if pages >= MAX_PAGES: + log.warning("run_channel hit MAX_PAGES=%s for source %s", MAX_PAGES, source.id) + break + finally: + if owns_http and isinstance(client, RateLimitedClient): + await client.aclose() + + return items diff --git a/backend/pipeline/collector.py b/backend/pipeline/collector.py index e1723dfd..75fa3aaf 100644 --- a/backend/pipeline/collector.py +++ b/backend/pipeline/collector.py @@ -8,6 +8,42 @@ async def collect(source: DataSource, parameters: dict[str, Any]) -> ChannelResult: - """Dispatch collection to the registered channel for the given source.""" + """Dispatch collection to the registered channel for the given source. + + Incremental channels (``capabilities.incremental``) collect through the thick + runner so they resume from a persisted cursor; every other channel keeps the + one-shot ``collect()`` path, unchanged. + """ channel = get_channel(source.channel_type) + if channel.capabilities.incremental: + return await _collect_incremental(source, parameters, channel) return await channel.collect(source.channel_config, parameters) + + +async def _collect_incremental( + source: DataSource, parameters: dict[str, Any], channel: Any +) -> ChannelResult: + """Collect an incremental channel via ``run_channel``, staging the cursor. + + The persisted cursor seeds the conditional fetch, but the advanced value is + held in an in-memory staging store and returned in ``metadata['cursor_pending']`` + — the pipeline commits it to the DB only after the write sink durably accepts + the batch, so the cursor never advances past data that did not land. + """ + from backend.pipeline.channel_runner import run_channel + from backend.pipeline.cursor_store import DBCursorStore, InMemoryCursorStore + + db_cursor = DBCursorStore() + staging = InMemoryCursorStore() + start = await db_cursor.load(source.id) + if start is not None: + await staging.save(source.id, start) + + items = await run_channel(source, parameters, cursor_store=staging, channel=channel) + staged = await staging.load(source.id) + + return ChannelResult.ok( + items, + cursor_pending=staged, + cursor_source_id=source.id, + ) diff --git a/backend/pipeline/cursor_store.py b/backend/pipeline/cursor_store.py new file mode 100644 index 00000000..7db9fcfc --- /dev/null +++ b/backend/pipeline/cursor_store.py @@ -0,0 +1,79 @@ +"""Per-source cursor persistence (Phase 1). + +A cursor is the channel's "where we left off" — an etag / last-modified for RSS, +a since_id for an id-based API, a since_ts for a time-based one, a page_token for +pagination. The runner loads it before fetching and saves what the channel +returns, so collection resumes incrementally instead of re-fetching everything +and survives crashes mid-pagination. + +This module is the seam: a ``CursorStore`` Protocol, an in-memory adapter for +tests and single-process use, and ``DBCursorStore`` backed by the +``source_cursors`` table. The runner depends only on the Protocol, so swapping +adapters changes nothing above. +""" + +from typing import Any, Protocol + + +class CursorStore(Protocol): + """Load/save a per-source cursor. The runner depends on this Protocol, never a + concrete store (accept dependencies, don't create them).""" + + async def load(self, source_id: str) -> dict[str, Any] | None: ... + + async def save(self, source_id: str, cursor: dict[str, Any]) -> None: ... + + +class InMemoryCursorStore: + """Process-local CursorStore. Used by tests and the not-yet-wired runner; the + DB adapter replaces it for real incremental collection.""" + + def __init__(self) -> None: + self._cursors: dict[str, dict[str, Any]] = {} + + async def load(self, source_id: str) -> dict[str, Any] | None: + return self._cursors.get(source_id) + + async def save(self, source_id: str, cursor: dict[str, Any]) -> None: + self._cursors[source_id] = dict(cursor) + + +class DBCursorStore: + """CursorStore backed by the ``source_cursors`` table. + + Owns a short-lived session per call (mirrors the sinks), so it satisfies the + runner's ``CursorStore`` Protocol without threading a session through. One row + per source, upserted on save. + """ + + async def load(self, source_id: str) -> dict[str, Any] | None: + from sqlalchemy import select + + from backend.database import AsyncSessionLocal + from backend.models.source_cursor import SourceCursor + + async with AsyncSessionLocal() as session: + row = ( + await session.execute( + select(SourceCursor).where(SourceCursor.source_id == source_id) + ) + ).scalar_one_or_none() + return dict(row.cursor) if row and row.cursor else None + + async def save(self, source_id: str, cursor: dict[str, Any]) -> None: + from sqlalchemy import select + + from backend.database import AsyncSessionLocal + from backend.models.source_cursor import SourceCursor + + async with AsyncSessionLocal() as session: + row = ( + await session.execute( + select(SourceCursor).where(SourceCursor.source_id == source_id) + ) + ).scalar_one_or_none() + if row is not None: + row.cursor = dict(cursor) + else: + session.add(SourceCursor(source_id=source_id, cursor=dict(cursor))) + await session.commit() diff --git a/backend/pipeline/domain_limiter.py b/backend/pipeline/domain_limiter.py new file mode 100644 index 00000000..73a8f603 --- /dev/null +++ b/backend/pipeline/domain_limiter.py @@ -0,0 +1,75 @@ +"""Process-global per-domain concurrency cap. + +Bounds how many collection runs touch the same host at once, so the fleet stays +polite to a site even when many sources target it. Applied at the task layer +(``runner.run_collection_pipeline``) so it covers every channel type, including +the browser-driven ones (opencli/skill) that don't go through ``run_channel``. + +In-process only: it limits concurrency within ONE worker. Strict cross-worker +limiting (a Celery fleet) would need a Redis-backed limiter behind ``domain_slot`` +— the same call site, a different implementation. +""" + +from __future__ import annotations + +import asyncio +import os +from contextlib import asynccontextmanager +from typing import Any +from urllib.parse import urlparse + +# Channel configs name their target with different keys; first hit wins. +_URL_KEYS = ("feed_url", "base_url", "url", "site", "endpoint") + +# (loop id, domain) -> semaphore. Keyed by loop so a semaphore is never reused +# across event loops (production runs one loop and shares correctly; tests get a +# fresh loop each and never touch a stale entry). +_semaphores: dict[tuple[int, str], asyncio.Semaphore] = {} + + +def _limit() -> int: + try: + return max(1, int(os.environ.get("PER_DOMAIN_CONCURRENCY", "3"))) + except ValueError: + return 3 + + +def domain_of(source: Any) -> str | None: + """Best-effort host for a source's target, for per-domain limiting. + + Reads the channel_config's first URL-ish field and extracts the host (auth + and port stripped, lowercased). Returns None when no host can be derived + (e.g. the cli channel) — meaning 'do not limit'. + """ + config = getattr(source, "channel_config", None) or {} + for key in _URL_KEYS: + val = config.get(key) + if not isinstance(val, str) or not val: + continue + netloc = urlparse(val if "://" in val else f"//{val}").netloc + host = netloc.split("@")[-1].split(":")[0].lower() + if host: + return host + return None + + +def _semaphore(domain: str) -> asyncio.Semaphore: + loop = asyncio.get_running_loop() + key = (id(loop), domain) + sem = _semaphores.get(key) + if sem is None: + sem = asyncio.Semaphore(_limit()) + _semaphores[key] = sem + return sem + + +@asynccontextmanager +async def domain_slot(source: Any): + """Hold a per-domain slot for the duration of a run. No-op when the source + has no derivable domain.""" + domain = domain_of(source) + if domain is None: + yield + return + async with _semaphore(domain): + yield diff --git a/backend/pipeline/http_client.py b/backend/pipeline/http_client.py new file mode 100644 index 00000000..d48c62d6 --- /dev/null +++ b/backend/pipeline/http_client.py @@ -0,0 +1,120 @@ +"""Runner HTTP infrastructure (Phase 1). + +A shared async client with a token-bucket rate limiter and retry/backoff baked +in, so a channel just does ``await ctx.http.get(...)`` and gets 429 handling, +Retry-After, and exponential backoff + jitter for free. This is exactly the kind +of cross-cutting concern the runner owns ONCE for every channel — never +reimplemented per source. +""" + +import asyncio +import logging +import random +import time +from typing import Any + +import httpx + +#: HTTP statuses the client retries (rate limit + transient server errors). +RETRY_STATUS = frozenset({429, 500, 502, 503}) + + +def parse_rate(rate: str) -> float: + """Parse a rate string like ``"60/min"`` into tokens per second. + + Supports ``/sec`` ``/second`` ``/min`` ``/minute`` ``/hour``. Falls back to + 1.0/s on anything unparseable so a bad config never crashes the runner. + """ + try: + count_str, _, unit = str(rate).partition("/") + count = float(count_str) + except (ValueError, AttributeError): + return 1.0 + per = { + "sec": 1.0, "second": 1.0, + "min": 60.0, "minute": 60.0, + "hour": 3600.0, + }.get(unit.strip().lower(), 60.0) + return count / per if per else count + + +class TokenBucket: + """Async token bucket. ``rate`` is tokens/second; ``capacity`` is the burst + size (defaults to ~one second of rate). ``acquire`` blocks only when the + bucket is empty, so steady traffic under the rate never waits.""" + + def __init__(self, rate: float, capacity: float | None = None) -> None: + self.rate = max(rate, 1e-6) + self.capacity = capacity if capacity is not None else max(1.0, rate) + self._tokens = self.capacity + self._updated = time.monotonic() + self._lock = asyncio.Lock() + + async def acquire(self, tokens: float = 1.0) -> None: + async with self._lock: + now = time.monotonic() + self._tokens = min(self.capacity, self._tokens + (now - self._updated) * self.rate) + self._updated = now + if self._tokens < tokens: + await asyncio.sleep((tokens - self._tokens) / self.rate) + self._tokens = 0.0 + self._updated = time.monotonic() + else: + self._tokens -= tokens + + +class RateLimitedClient: + """Wraps an ``httpx.AsyncClient``: every request first waits on the token + bucket, then retries on 429/5xx with exponential backoff + jitter, honoring a + numeric ``Retry-After``. A channel sees a plain get/post; the cross-cutting + policy lives here, once.""" + + def __init__( + self, + client: httpx.AsyncClient, + bucket: TokenBucket, + *, + max_retries: int = 5, + log: logging.Logger | None = None, + ) -> None: + self._client = client + self._bucket = bucket + self._max = max_retries + self._log = log + + async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: + attempt = 0 + while True: + await self._bucket.acquire() + resp = await self._client.request(method, url, **kwargs) + if resp.status_code not in RETRY_STATUS or attempt >= self._max: + return resp + delay = self._retry_after(resp) + if delay is None: + delay = min(60.0, 2.0 ** attempt) + random.uniform(0.0, 0.5) + attempt += 1 + if self._log: + self._log.warning( + "retry %s %s after %.1fs (status %s, attempt %s/%s)", + method, url, delay, resp.status_code, attempt, self._max, + ) + await asyncio.sleep(delay) + + async def get(self, url: str, **kwargs: Any) -> httpx.Response: + return await self.request("GET", url, **kwargs) + + async def post(self, url: str, **kwargs: Any) -> httpx.Response: + return await self.request("POST", url, **kwargs) + + async def aclose(self) -> None: + await self._client.aclose() + + @staticmethod + def _retry_after(resp: httpx.Response) -> float | None: + value = resp.headers.get("retry-after") + if not value: + return None + try: + return float(value) # delta-seconds form + except ValueError: + return None # HTTP-date form: ignore, fall back to exponential backoff diff --git a/backend/pipeline/odp_client.py b/backend/pipeline/odp_client.py index 9189a107..9cc31cde 100644 --- a/backend/pipeline/odp_client.py +++ b/backend/pipeline/odp_client.py @@ -1,13 +1,24 @@ -"""HTTP client for ODP ingest API (Rust hot path).""" +"""HTTP client for ODP ingest API (Rust hot path). + +The wire shape (:class:`RecordEvent`) and the response +(:class:`OdpIngestResponse`) live in :mod:`backend.odp` as a typed mirror of the +Rust ``odp-contracts`` crate. This module is just the transport: build events +through the mapper, POST the batch, parse the response. One shape definition is +what lets a later step move this forward into an ``OdpSink`` behind a +characterization test proving the bytes are unchanged. +""" from __future__ import annotations import logging import os -from datetime import datetime, timezone from typing import Any + import httpx +from backend.odp.mapper import RecordEventMapper +from backend.odp.schemas import OdpIngestResponse + logger = logging.getLogger(__name__) INGEST_TIMEOUT = float(os.environ.get("ODP_INGEST_TIMEOUT", "10")) @@ -18,10 +29,6 @@ def ingest_url() -> str | None: return base or None -def _provider_for_channel(channel_type: str) -> str: - return f"opencli-admin/{channel_type}" - - def triple_to_event( *, channel_type: str, @@ -31,30 +38,19 @@ def triple_to_event( normalized: dict[str, Any], content_hash: str, ) -> dict[str, Any]: - published = normalized.get("published_at") or "" - try: - if published: - source_ts = datetime.fromisoformat(published.replace("Z", "+00:00")) - if source_ts.tzinfo is None: - source_ts = source_ts.replace(tzinfo=timezone.utc) - else: - source_ts = datetime.now(timezone.utc) - except ValueError: - source_ts = datetime.now(timezone.utc) - - return { - "schema_version": 1, - "provider": _provider_for_channel(channel_type), - "source_id": str(source_id), - "event_id": content_hash, - "ingest_mode": "snapshot", - "source_ts": source_ts.isoformat().replace("+00:00", "Z"), - "cursor": None, - "payload": normalized, - "raw_data": raw, - "trace_id": None, - "task_id": str(task_id), - } + """Normalized triple -> ODP wire event. + + Thin wrapper over :class:`RecordEventMapper` so the legacy forward path and + the future ``OdpSink`` share exactly one shape definition. + """ + return RecordEventMapper.from_triple( + channel_type=channel_type, + source_id=source_id, + task_id=task_id, + raw=raw, + normalized=normalized, + content_hash=content_hash, + ).to_wire() async def post_batch( @@ -74,18 +70,16 @@ async def post_batch( resp.raise_for_status() data = resp.json() - accepted = int(data.get("accepted", 0)) - duplicates = int(data.get("duplicates", 0)) - rejected = int(data.get("rejected", 0)) + parsed = OdpIngestResponse.from_wire(data) logger.info( "odp ingest | channel=%s sent=%d accepted=%d duplicates=%d rejected=%d", channel_type, len(events), - accepted, - duplicates, - rejected, + parsed.accepted, + parsed.duplicates, + parsed.rejected, ) - return accepted, duplicates, rejected + return parsed.accepted, parsed.duplicates, parsed.rejected async def forward_triples( @@ -106,4 +100,4 @@ async def forward_triples( ) for raw, normalized, content_hash in triples ] - return await post_batch(events, channel_type=channel_type) \ No newline at end of file + return await post_batch(events, channel_type=channel_type) diff --git a/backend/pipeline/pipeline.py b/backend/pipeline/pipeline.py index 9c30358b..62cb7cb4 100644 --- a/backend/pipeline/pipeline.py +++ b/backend/pipeline/pipeline.py @@ -33,17 +33,34 @@ async def run_pipeline( enable_notifications: bool = True, agent_config: dict[str, Any] | None = None, run_id: str | None = None, + sink=None, # ItemSink | None — write destination; defaults to LegacyDbSink ) -> PipelineResult: """Execute the full collection pipeline. Each write step uses its own short-lived session so no write lock is held during long-running I/O.""" from backend.database import AsyncSessionLocal - from backend.pipeline import ai_processor, collector, notifier_dispatch, normalizer, storer + from backend.pipeline import ai_processor, collector, notifier_dispatch started = datetime.now(timezone.utc) params = parameters or {} - # Pre-step: auto-resolve chrome endpoint from browser binding (opencli only) - if source.channel_type == "opencli" and not params.get("chrome_endpoint"): + # Pre-step: auto-resolve chrome endpoint from a browser binding. Channels that + # declare capabilities.session_affinity (opencli, skill) drive a real Chrome + # from the shared pool, so a site-keyed binding lets them attach to a + # logged-in browser. Best-effort: a missing binding is not an error + # (browser_pool.acquire(endpoint=None) picks a default), so we only override + # chrome_endpoint when a binding exists. Gated by the capability rather than a + # hardcoded channel list, so a new session-bound channel needs no change here. + from backend.channels.registry import get_channel + + try: + _affinity_channel = get_channel(source.channel_type) + except Exception: + _affinity_channel = None # unknown channel_type surfaces in the collect step + if ( + _affinity_channel is not None + and _affinity_channel.capabilities.session_affinity + and not params.get("chrome_endpoint") + ): site = source.channel_config.get("site", "") if site: from backend.services import browser_service @@ -60,7 +77,20 @@ async def run_pipeline( step1_start = datetime.now(timezone.utc) if run_id: + # Skill channel: inject run_id into params BEFORE dispatch so the loop can + # emit per-step events via events.emit(run_id, ...). Scoped to "skill" — + # other channels don't expect a run_id param. (chrome_endpoint, if any, + # was already injected by the pre-step binding above.) + if source.channel_type == "skill": + params = {**params, "run_id": run_id} collect_detail: dict = {"channel_type": source.channel_type, "params": params} + if source.channel_type == "skill": + _skill_md = source.channel_config.get("skill_md") or "" + collect_detail["skill"] = { + "skill_chars": len(_skill_md), + "has_chrome_endpoint": bool(params.get("chrome_endpoint")), + "auto_confirm": bool(source.channel_config.get("auto_confirm", False)), + } if source.channel_type == "opencli": from backend.channels.opencli_channel import _get_named_options, _OPENCLI_BIN cfg = source.channel_config @@ -126,41 +156,63 @@ async def run_pipeline( elapsed_ms=step1_elapsed, ) - # Step 2: Normalize - triples = normalizer.normalize_items(channel_result.items, source.id) - logger.info("[task:%s] step2/normalize done | items=%d", task_id, len(triples)) - if run_id: - await events.emit( - run_id, "normalize", - f"归一化完成 | {len(triples)} 条", - detail={"items": len(triples)}, - ) + # Steps 2+3: Normalize + Store, behind the write seam. The sink owns its own + # normalization, dedup, and persistence; the orchestrator stays + # destination-agnostic. An explicitly injected sink wins (tests, callers); + # otherwise the source's write_strategy selects it (default 'legacy' → + # LegacyDbSink, the original inline path). + from backend.pipeline.sinks.base import RunContext + from backend.pipeline.sinks.strategy import select_sink - # Step 3: Store - logger.info("[task:%s] step3/store start | items=%d", task_id, len(triples)) + active_sink = sink or select_sink(getattr(source, "write_strategy", None)) + sink_ctx = RunContext( + task_id=task_id, + source_id=source.id, + provider=source.channel_type, + run_id=run_id, + ) + logger.info("[task:%s] step2-3/sink start | sink=%s items=%d", + task_id, type(active_sink).__name__, channel_result.count) try: - async with AsyncSessionLocal() as session: - new_records, skipped = await storer.store_records( - session, task_id, source.id, triples, channel_type=source.channel_type - ) - await session.commit() + sink_result = await active_sink.write_batch(sink_ctx, channel_result.items) except Exception as exc: - logger.exception("[task:%s] step3/store exception | %s", task_id, exc) + logger.exception("[task:%s] step2-3/sink exception | %s", task_id, exc) return PipelineResult( success=False, source_id=source.id, collected=channel_result.count, error=str(exc), ) - logger.info("[task:%s] step3/store done | new=%d skipped=%d", - task_id, len(new_records), skipped) + new_records = sink_result.records + skipped = sink_result.duplicates + logger.info("[task:%s] step2-3/sink done | normalized=%d new=%d skipped=%d", + task_id, sink_result.normalized, len(new_records), skipped) if run_id: + await events.emit( + run_id, "normalize", + f"归一化完成 | {sink_result.normalized} 条", + detail={"items": sink_result.normalized}, + ) await events.emit( run_id, "store", f"入库完成 | 新增 {len(new_records)} 条,跳过 {skipped} 条(重复)", detail={"new": len(new_records), "skipped": skipped}, ) + # Incremental cursor: advance the persisted cursor ONLY now that the write sink + # has accepted this batch. A raised/failed sink returned above, so reaching here + # means the data landed; committing during fetch would skip items that never got + # written. (Deeper ODP durability — a queued 202 that never persists — is an + # ODP-side guarantee, tracked separately.) + pending_cursor = channel_result.metadata.pop("cursor_pending", None) + cursor_source_id = channel_result.metadata.pop("cursor_source_id", None) + if pending_cursor is not None and cursor_source_id is not None: + from backend.pipeline.cursor_store import DBCursorStore + + await DBCursorStore().save(cursor_source_id, pending_cursor) + logger.info("[task:%s] cursor committed post-write | source=%s", + task_id, cursor_source_id) + # Step 4: AI processing effective_ai_config = agent_config or source.ai_config ai_count = 0 diff --git a/backend/pipeline/runner.py b/backend/pipeline/runner.py index f54898ab..dbe1a72c 100644 --- a/backend/pipeline/runner.py +++ b/backend/pipeline/runner.py @@ -12,6 +12,14 @@ logger = logging.getLogger(__name__) +# Generic best-effort enrichment prompt used by the auto-default agent. +# Unfilled {{placeholders}} render to empty strings (see OpenAIProcessor._render). +DEFAULT_ENRICH_PROMPT = ( + "分析下面这条采集记录, 只返回一个 JSON 对象, 字段: " + '{"summary": "一句话摘要", "tags": ["关键词"], "category": "分类"}。\n' + "标题: {{title}}\n内容: {{content}}{{text}}{{description}}\n链接: {{url}}" +) + async def run_collection_pipeline( task_id: str, @@ -105,6 +113,33 @@ async def run_collection_pipeline( **provider_config, **agent.processor_config, # agent-level overrides provider } + + # Autonomous default (N8N-style: configure the credential once, the AI node + # just works). No explicit agent → auto-use the first enabled provider with a + # generic enrichment prompt. So configuring a provider is enough — no per-agent setup. + if agent_config is None: + from backend.models.provider import ModelProvider + result = await session.execute( + select(ModelProvider) + .where(ModelProvider.enabled.is_(True)) + .order_by(ModelProvider.created_at.asc()) + ) + provider = result.scalars().first() + if provider: + cfg: dict = {} + if provider.api_key: + cfg["api_key"] = provider.api_key + if provider.base_url: + cfg["base_url"] = provider.base_url + agent_config = { + "processor_type": "openai", # OpenAI-compatible: covers Ollama/local/openai gateways + "model": provider.default_model, + "prompt_template": DEFAULT_ENRICH_PROMPT, + **cfg, + } + logger.info("[task:%s] auto default agent | provider=%s model=%s", + task_id, provider.name, provider.default_model) + # Detach source from session so it can be used after session closes session.expunge(source) logger.info("[task:%s] phase2 done | source=%s channel=%s agent_config=%s", @@ -112,13 +147,18 @@ async def run_collection_pipeline( {k: v for k, v in (agent_config or {}).items() if k != "prompt_template"}) # ── Phase 3: run pipeline (no session held during collection) ───────────── - pipeline_result = await run_pipeline( - task_id=task_id, - source=source, - parameters=merged_params, - agent_config=agent_config, - run_id=run_id, - ) + # Hold a per-domain slot for the run so the fleet stays polite to a site even + # when many sources target it (in-process cap; cross-worker would need Redis). + from backend.pipeline.domain_limiter import domain_slot + + async with domain_slot(source): + pipeline_result = await run_pipeline( + task_id=task_id, + source=source, + parameters=merged_params, + agent_config=agent_config, + run_id=run_id, + ) # ── Phase 4: persist final status ──────────────────────────────────────── async with AsyncSessionLocal() as session: @@ -132,7 +172,19 @@ async def run_collection_pipeline( if pipeline_result.metadata.get("node_url"): run.node_url = pipeline_result.metadata["node_url"] - if pipeline_result.success: + # Paused outcome (skill execute loop hit the confirm gate in headless v1): + # a successful pipeline run that stopped at a confirm-required action. + # collect/normalize/store all ran (finished_at/duration/records above are + # still set); the run just paused, so it is neither completed nor failed. + # TaskRun.status / CollectionTask.status are free-text String(50) so + # storing "awaiting_confirm" needs no migration (anchor is issue 04). + if pipeline_result.metadata.get("awaiting_confirm"): + if task: + task.status = "awaiting_confirm" + task.error_message = None + if run: + run.status = "awaiting_confirm" + elif pipeline_result.success: if task: task.status = "completed" task.error_message = None diff --git a/backend/pipeline/sinks/__init__.py b/backend/pipeline/sinks/__init__.py new file mode 100644 index 00000000..bfccdc32 --- /dev/null +++ b/backend/pipeline/sinks/__init__.py @@ -0,0 +1,22 @@ +"""Write sinks: the destination seam for collected items. + +Import the seam from here: + + from backend.pipeline.sinks import LegacyDbSink, OdpSink, DualSink, ItemSink, RunContext, SinkResult +""" + +from backend.pipeline.sinks.base import ItemSink, RunContext, SinkResult +from backend.pipeline.sinks.legacy_db_sink import LegacyDbSink +from backend.pipeline.sinks.odp_sink import OdpSink +from backend.pipeline.sinks.dual_sink import DualSink +from backend.pipeline.sinks.strategy import select_sink + +__all__ = [ + "ItemSink", + "RunContext", + "SinkResult", + "LegacyDbSink", + "OdpSink", + "DualSink", + "select_sink", +] diff --git a/backend/pipeline/sinks/base.py b/backend/pipeline/sinks/base.py new file mode 100644 index 00000000..56570d96 --- /dev/null +++ b/backend/pipeline/sinks/base.py @@ -0,0 +1,80 @@ +"""The write seam: where collected items go. + +A channel fetches; the runner orchestrates; a **Sink** decides the destination. +Today the only destination is the legacy ``collected_records`` table +(``LegacyDbSink``). Next, the same items also flow to the ODP hot path +(``OdpSink``), and both at once for shadow validation (``DualSink``) — all behind +this one interface, chosen per source by ``write_strategy``, with no change to +channels or the runner. + +This is the strangler-fig seam: the old path keeps working unchanged, the new +path is wired in beside it, and a source is migrated by flipping its strategy — +never by rewriting the pipeline. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol, Sequence + + +@dataclass +class RunContext: + """Identity of one collection run, threaded to whichever sink handles it. + + ``provider`` is the channel_type (e.g. ``"rss"``); it becomes the ODP + ``provider`` and the legacy ``channel_type``. ``ingest_mode`` is + ``snapshot`` (full re-list) or ``stream`` (incremental), mirroring the ODP + contract. + """ + + task_id: str + source_id: str + provider: str + ingest_mode: str = "snapshot" + run_id: str | None = None + trace_id: str | None = None + + +@dataclass +class SinkResult: + """Outcome of writing one batch. + + Counts share one vocabulary across sinks, but each is defined relative to + that sink's OWN durable boundary — not a shared one. A DualSink comparison + must account for the boundaries differing: + + * ``accepted`` — items the sink committed to its durable path. For + ``LegacyDbSink`` this is rows inserted into ``collected_records``; for + ``OdpSink`` it is events the ingest service *queued* (Redis Stream) — + a weaker guarantee than an inserted row. + * ``duplicates`` — items the sink recognized as already-seen before its + durable write (legacy: ``content_hash`` hit; ODP: ``(source_id, event_id)``). + * ``rejected`` — items dropped for validation or a permanent error; + detail in ``errors``. + * ``normalized`` — items that passed normalization (legacy bookkeeping). + * ``records`` — persisted ORM rows, for sinks that own a local table + (``LegacyDbSink``), so the downstream AI/notify steps can enrich them. + Forward-only sinks (``OdpSink``) leave it empty and those steps no-op, + because on the ODP path enrichment happens off the ``record.committed`` + stream. + """ + + accepted: int = 0 + duplicates: int = 0 + rejected: int = 0 + normalized: int = 0 + records: list[Any] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +class ItemSink(Protocol): + """Accepts raw collected items and persists/forwards them somewhere. + + Implementations own their own normalization, dedup, and persistence so the + orchestrator stays destination-agnostic. The whole surface is one method: + everything else a sink does is hidden behind it. + """ + + async def write_batch(self, ctx: RunContext, items: Sequence[dict]) -> SinkResult: + ... diff --git a/backend/pipeline/sinks/dual_sink.py b/backend/pipeline/sinks/dual_sink.py new file mode 100644 index 00000000..ab23f4b3 --- /dev/null +++ b/backend/pipeline/sinks/dual_sink.py @@ -0,0 +1,61 @@ +"""DualSink — legacy DB write (authoritative) + ODP shadow forward (exactly once). + +Shadow-validation destination: the legacy table stays the source of truth (its +records feed the AI/notify steps), and the same items are forwarded to ODP once +for comparison. The legacy leg is constructed with ``forward_to_odp=False`` so +the storer's own forward stays off — otherwise ODP would receive the batch twice +(once from storer, once from OdpSink) and the shadow metrics would be polluted. + +ODP being down must never block the legacy write, so the forward is best-effort: +failures are logged and recorded in ``SinkResult.errors``, and the legacy result +is returned unchanged. +""" + +from __future__ import annotations + +import logging +from typing import Sequence + +from backend.pipeline.sinks.base import ItemSink, RunContext, SinkResult +from backend.pipeline.sinks.legacy_db_sink import LegacyDbSink +from backend.pipeline.sinks.odp_sink import OdpSink + +logger = logging.getLogger(__name__) + + +class DualSink: + """Write to the legacy table and shadow-forward to ODP without double-sending.""" + + def __init__( + self, + legacy: ItemSink | None = None, + odp: ItemSink | None = None, + require_odp: bool = False, + ) -> None: + # The default legacy leg must NOT forward — OdpSink is the single ODP sender. + self.legacy = legacy if legacy is not None else LegacyDbSink(forward_to_odp=False) + self.odp = odp if odp is not None else OdpSink() + # require_odp=True (odp_dual_required / odp_primary): an ODP failure is + # surfaced (re-raised) instead of swallowed — the dual-write invariant must + # hold. Default False (odp_shadow): ODP is best-effort. + self.require_odp = require_odp + + async def write_batch(self, ctx: RunContext, items: Sequence[dict]) -> SinkResult: + result = await self.legacy.write_batch(ctx, items) # authoritative + try: + shadow = await self.odp.write_batch(ctx, items) + logger.info( + "odp shadow | accepted=%d duplicates=%d rejected=%d", + shadow.accepted, + shadow.duplicates, + shadow.rejected, + ) + except Exception as exc: + if self.require_odp: + # Dual-write required: surface the failure even though legacy wrote. + logger.error("odp forward failed under require_odp: %s", exc) + raise + # Shadow mode: ODP must never break the legacy path. + logger.warning("odp shadow forward failed (legacy unaffected): %s", exc) + result.errors.append(f"odp shadow: {exc}") + return result diff --git a/backend/pipeline/sinks/legacy_db_sink.py b/backend/pipeline/sinks/legacy_db_sink.py new file mode 100644 index 00000000..fc7a9188 --- /dev/null +++ b/backend/pipeline/sinks/legacy_db_sink.py @@ -0,0 +1,63 @@ +"""LegacyDbSink — the original write path, now behind the ItemSink seam. + +Normalizes items (``content_hash`` dedup) and stores them in +``collected_records``, exactly as the pipeline did inline before the seam +existed. Extracting it changes no behavior: it still calls +``normalizer.normalize_items`` then ``storer.store_records`` inside a +short-lived session. + +Two things stay where they were on purpose, to keep this slice behavior-only: + * The ODP forward still lives inside ``storer.store_records`` (fires when + ``ODP_INGEST_URL`` is set), now behind a ``forward_to_odp`` gate so DualSink + can suppress it on the legacy leg. The dedicated ``OdpSink`` owns the forward + going forward; ``write_strategy`` picks the destination in a later slice. + * Dedup here remains ``content_hash`` (title|url|content|source_id). The ODP + path keys on ``(source_id, event_id)`` instead; the two will disagree, and + surfacing that disagreement under shadow is the point of the migration. +""" + +from __future__ import annotations + +from typing import Sequence + +from backend.pipeline.sinks.base import RunContext, SinkResult + + +class LegacyDbSink: + """Persist collected items to the legacy ``collected_records`` table. + + ``forward_to_odp`` gates the ODP shadow-forward that lives inside + ``storer.store_records``. Defaults to True (behavior unchanged); ``DualSink`` + constructs this with False so the legacy write does not double-send to ODP + alongside ``OdpSink``. + """ + + def __init__(self, forward_to_odp: bool = True) -> None: + self.forward_to_odp = forward_to_odp + + async def write_batch(self, ctx: RunContext, items: Sequence[dict]) -> SinkResult: + # Function-local imports mirror the orchestrator: ``AsyncSessionLocal`` is + # rebound per call so tests can patch ``backend.database.AsyncSessionLocal``, + # and ``storer``/``normalizer`` are reached as module attributes so + # ``patch("backend.pipeline.storer.store_records")`` takes effect. + from backend.database import AsyncSessionLocal + from backend.pipeline import normalizer, storer + + triples = normalizer.normalize_items(list(items), ctx.source_id) + + # The ODP shadow-forward still fires inside storer.store_records; the + # forward_to_odp gate lets DualSink(LegacyDbSink + OdpSink) turn it off on + # the legacy leg so ODP is not double-sent. + async with AsyncSessionLocal() as session: + new_records, skipped = await storer.store_records( + session, ctx.task_id, ctx.source_id, triples, + channel_type=ctx.provider, forward_to_odp=self.forward_to_odp, + ) + await session.commit() + + return SinkResult( + accepted=len(new_records), + duplicates=skipped, + normalized=len(triples), + records=new_records, + ) diff --git a/backend/pipeline/sinks/odp_sink.py b/backend/pipeline/sinks/odp_sink.py new file mode 100644 index 00000000..af5584c3 --- /dev/null +++ b/backend/pipeline/sinks/odp_sink.py @@ -0,0 +1,41 @@ +"""OdpSink — forward collected items to the Rust ODP ingest hot path. + +Forward-only: it normalizes and posts events through ``odp_client`` but owns no +local table, so ``SinkResult.records`` is empty and the pipeline's AI/notify +steps no-op (on the ODP path that enrichment happens off the ``record.committed`` +stream, not here). + +``accepted`` here means *queued* by the ingest service (a Redis Stream), a weaker +guarantee than ``LegacyDbSink``'s inserted row — see ``SinkResult``. +""" + +from __future__ import annotations + +from typing import Sequence + +from backend.pipeline.sinks.base import RunContext, SinkResult + + +class OdpSink: + """Post collected items to odp-ingest via the shared mapper/client.""" + + async def write_batch(self, ctx: RunContext, items: Sequence[dict]) -> SinkResult: + from backend.pipeline import normalizer, odp_client + + triples = normalizer.normalize_items(list(items), ctx.source_id) + if not triples: + return SinkResult() + + accepted, duplicates, rejected = await odp_client.forward_triples( + channel_type=ctx.provider, + task_id=ctx.task_id, + source_id=ctx.source_id, + triples=triples, + ) + return SinkResult( + accepted=accepted, + duplicates=duplicates, + rejected=rejected, + normalized=len(triples), + records=[], + ) diff --git a/backend/pipeline/sinks/strategy.py b/backend/pipeline/sinks/strategy.py new file mode 100644 index 00000000..9cac99b6 --- /dev/null +++ b/backend/pipeline/sinks/strategy.py @@ -0,0 +1,55 @@ +"""write_strategy -> ItemSink. The state machine that picks a write destination. + +Once a source declares an explicit strategy, the ODP forward is no longer an +implicit env-var side effect buried in the legacy path — it is chosen here. The +default ``legacy`` preserves today's behavior exactly (``LegacyDbSink`` with its +env-gated shadow-forward still intact); every other strategy makes the ODP write +explicit via ``OdpSink`` / ``DualSink`` and turns the storer's own forward off so +nothing double-sends. +""" + +from __future__ import annotations + +import logging + +from backend.pipeline.sinks.base import ItemSink +from backend.pipeline.sinks.dual_sink import DualSink +from backend.pipeline.sinks.legacy_db_sink import LegacyDbSink +from backend.pipeline.sinks.odp_sink import OdpSink + +logger = logging.getLogger(__name__) + +LEGACY = "legacy" +ODP_SHADOW = "odp_shadow" +ODP_DUAL_REQUIRED = "odp_dual_required" +ODP_PRIMARY = "odp_primary" +ODP_ONLY = "odp_only" + +WRITE_STRATEGIES = frozenset( + {LEGACY, ODP_SHADOW, ODP_DUAL_REQUIRED, ODP_PRIMARY, ODP_ONLY} +) + + +def select_sink(strategy: str | None) -> ItemSink: + """Map a source's ``write_strategy`` to a sink instance. + + Migration states: + * ``legacy`` — DB write + the original env-gated ODP shadow. + * ``odp_shadow`` — DB authoritative + ODP best-effort, forwarded once. + * ``odp_dual_required`` — DB + ODP, ODP failure is surfaced (invariant). + * ``odp_primary`` — DB + ODP required; ODP is the read source of truth + (a cutover marker). Read-routing is outside the write pipeline, so the + write path equals ``odp_dual_required`` for now. + * ``odp_only`` — ODP only, no DB row. + + Unknown/None falls back to ``legacy`` (safe default) with a warning. + """ + if strategy == ODP_ONLY: + return OdpSink() + if strategy == ODP_SHADOW: + return DualSink(require_odp=False) + if strategy in (ODP_DUAL_REQUIRED, ODP_PRIMARY): + return DualSink(require_odp=True) + if strategy not in (None, LEGACY): + logger.warning("unknown write_strategy %r — falling back to legacy", strategy) + return LegacyDbSink() diff --git a/backend/pipeline/storer.py b/backend/pipeline/storer.py index 1542b9b4..d140bbee 100644 --- a/backend/pipeline/storer.py +++ b/backend/pipeline/storer.py @@ -19,19 +19,22 @@ async def store_records( normalized_triples: list[tuple[dict, dict, str]], *, channel_type: str = "unknown", + forward_to_odp: bool = True, ) -> tuple[list[CollectedRecord], int]: """Insert new records; skip existing ones by content_hash. - When ``ODP_INGEST_URL`` is set, events are forwarded to the Rust ingest - service first (hot path). SQLite/ORM write remains for pipeline AI/notify - until those steps move to async ``record.committed`` consumers. + When ``ODP_INGEST_URL`` is set AND ``forward_to_odp`` is True, events are + forwarded to the Rust ingest service first (hot path). SQLite/ORM write + remains for pipeline AI/notify until those steps move to async + ``record.committed`` consumers. ``DualSink`` passes ``forward_to_odp=False`` + so the legacy write does not double-send alongside ``OdpSink``. Returns (new_records, skipped_count). """ if not normalized_triples: return [], 0 - if odp_client.ingest_url(): + if forward_to_odp and odp_client.ingest_url(): try: await odp_client.forward_triples( channel_type=channel_type, @@ -56,11 +59,17 @@ async def store_records( new_records: list[CollectedRecord] = [] skipped = 0 + # Dedup within this batch too: two triples can share a content_hash (e.g. two + # CLI sub-commands that normalize to identical content). Without this, both + # pass the existing_hashes check, both get added, and flush() fails the whole + # batch atomically on the UNIQUE(source_id, content_hash) constraint. + seen_in_batch: set[str] = set() for raw, normalized, content_hash in normalized_triples: - if content_hash in existing_hashes: + if content_hash in existing_hashes or content_hash in seen_in_batch: skipped += 1 continue + seen_in_batch.add(content_hash) record = CollectedRecord( task_id=task_id, diff --git a/backend/skills/__init__.py b/backend/skills/__init__.py new file mode 100644 index 00000000..2aba223d --- /dev/null +++ b/backend/skills/__init__.py @@ -0,0 +1,20 @@ +"""Skill subsystem: distill execution traces into reusable SKILL.md cards. + +Closed loop (BrowserBC path-B, integrated into opencli-admin): + record → a human/agent browser trace (journey_trace_v1) + distill → trace + provider LLM → 9-element skill spec + SKILL.md (this pkg) + store → Skill model (models/skill.py) + execute → skill_channel reads SKILL.md, cheap model drives CDP page + correct → run events feed self-eval back into a new distill round +""" + +from backend.skills.distill import distill_trace, provider_from_model +from backend.skills.trace import TRACE_SCHEMA, assemble_trace, self_eval + +__all__ = [ + "TRACE_SCHEMA", + "assemble_trace", + "distill_trace", + "provider_from_model", + "self_eval", +] diff --git a/backend/skills/actions.py b/backend/skills/actions.py new file mode 100644 index 00000000..9ecb9830 --- /dev/null +++ b/backend/skills/actions.py @@ -0,0 +1,216 @@ +"""Action executor — fixed verb set → SkillPage ops, ref-addressed (ADR-0003 D3). + +This is the deterministic **act** primitive of the skill execute loop. It takes +*one* structured action the cheap model already chose (issue 03 picks it; issue +04 gates it) and performs it on a CDP-attached page, returning a uniform +structured result. It decides **nothing** about *which* action runs and never +classifies risk, opens a DB session, emits events, or builds a ``ChannelResult`` +— those are issues 03/04/05. + +Hard red line (ADR-0003 D2/D3): the verb set is exactly the fixed 7 below; any +other verb — explicitly including ``evaluate``/``js`` — is rejected with a +structured error, never executed. There is **no** model-facing JS escape hatch. + +Module shape mirrors ``backend/skills/distill.py`` and +``backend/channels/opencli_channel.py``: a pure sync validator +(:func:`validate_action`) + pure ref resolver (:func:`resolve_ref`) tested +directly, and a single async dispatch (:func:`execute_action`) tested against a +mock ``SkillPage``. It imports nothing from ``backend.pipeline`` / +``backend.database`` / ``backend.models``; ``SkillPage`` is imported only under +``TYPE_CHECKING`` so the module loads with no browser / Playwright present. +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # typing only — no runtime browser/Playwright import + from backend.skills.page import SkillPage + +# ── Fixed verb set: single source of truth (ADR-0003 D3) ─────────────────────── +# The 7 allowed verbs and their required/optional fields. This is the skill +# loop's OWN schema — deliberately NOT the chat-console TOOLS/WRITE_TOOLS in +# backend/api/v1/chat.py (PRD §4 D3). No verb may be added here, and there is no +# ``evaluate``/``js`` entry — that omission is what rejects the JS escape hatch. +VERBS: dict[str, dict[str, tuple[str, ...]]] = { + "navigate": {"required": ("url",), "optional": ()}, + "click": {"required": ("ref",), "optional": ()}, + "type": {"required": ("ref", "text"), "optional": ("submit",)}, + "select": {"required": ("ref", "value"), "optional": ()}, + "scroll": {"required": ("dir",), "optional": ()}, + "extract": {"required": ("data",), "optional": ()}, + "done": {"required": ("status",), "optional": ("note",)}, +} + +# Verbs that address a DOM element by ``ref`` — they get ref-resolved against the +# current snapshot before the page is touched. The rest are not ref-addressed. +_REF_VERBS = frozenset({"click", "type", "select"}) + + +@dataclass +class ActionResult: + """Uniform result of one executed action. + + Returned for *every* call — success and the expected failure cases alike + (unknown verb, bad/stale ref, missing field, page-op error). Mirrors + ``ChannelResult``'s ``ok``/``fail`` classmethod style (``backend/channels/ + base.py``). Never raised; expected failures become ``failure(...)``. + + Fields: + * ``ok`` — True on success, False on any handled failure. + * ``verb`` — the echoed verb (None when the verb itself was invalid). + * ``error`` — failure message (None on success). + * ``record`` — only set by ``extract``; destined for + ``ChannelResult.items`` (issue 05 appends it). + * ``terminal`` — only True for ``done``; the loop-termination signal. + * ``detail`` — extra context, e.g. ``{"url"}`` / ``{"ref"}`` acted on, + or ``{"status","note"}`` for ``done``. + """ + + ok: bool + verb: str | None = None + error: str | None = None + record: dict[str, Any] | None = None + terminal: bool = False + detail: dict[str, Any] = field(default_factory=dict) + + @classmethod + def success( + cls, + verb: str, + *, + record: dict[str, Any] | None = None, + terminal: bool = False, + detail: dict[str, Any] | None = None, + ) -> "ActionResult": + return cls( + ok=True, + verb=verb, + record=record, + terminal=terminal, + detail=detail or {}, + ) + + @classmethod + def failure(cls, verb: str | None, error: str) -> "ActionResult": + return cls(ok=False, verb=verb, error=error) + + +def validate_action(action: dict[str, Any]) -> str | None: + """Validate one action dict against :data:`VERBS`. Pure; never raises. + + Returns an error string when the action is invalid, or ``None`` when it is + well-formed. Distinct messages for: not a dict, missing/empty ``verb``, a + verb not in the fixed set (this is the ``evaluate``/``js`` rejection), and + each missing required field. + """ + if not isinstance(action, dict): + return f"action must be a dict, got {type(action).__name__}" + + verb = action.get("verb") + if not verb: + return "missing 'verb'" + if verb not in VERBS: + # Any verb outside the fixed 7 — including evaluate/js — is rejected here. + return f"unknown verb: {verb!r} (allowed: {sorted(VERBS)})" + + for fieldname in VERBS[verb]["required"]: + if fieldname not in action: + return f"verb {verb!r} missing required field: {fieldname!r}" + return None + + +def resolve_ref( + snapshot: list[dict[str, Any]], ref: Any +) -> dict[str, Any] | None: + """Return the snapshot entry whose ``ref`` matches ``ref``, else ``None``. + + Pure helper. Compares as strings to tolerate int/str refs (the snapshot's + ``ref`` is an int from ``perception.project_snapshot``; the model may emit it + as a string). "Resolution" is membership/validity in the *current* snapshot; + the actual element lookup on the page is done by ``SkillPage`` by ref (it set + ``data-skill-ref`` during perception). A ``None`` return means the ref is + stale/unknown — the caller turns that into a structured failure *before* + touching the page. + """ + if not snapshot: + return None + target = str(ref) + for entry in snapshot: + if isinstance(entry, dict) and str(entry.get("ref")) == target: + return entry + return None + + +async def execute_action( + page: "SkillPage", + snapshot: list[dict[str, Any]], + action: dict[str, Any], +) -> ActionResult: + """Perform one validated action on ``page``; return a structured result. + + Order: :func:`validate_action` → (for ref verbs) :func:`resolve_ref` → call + the matching ``SkillPage`` method → build the result. Never raises for the + expected failure cases — unknown verb, bad/stale ref, missing field, and any + page-op ``Exception`` are converted to ``ActionResult.failure(...)`` (the + same best-effort discipline as ``events.emit``). + + Does not decide *which* action to run (issue 03), classify risk (issue 04), + or wire records/terminal into the pipeline (issue 05). + """ + err = validate_action(action) + if err is not None: + return ActionResult.failure(action.get("verb"), err) + + verb = action["verb"] + + # extract / done touch no page op — handle before resolution/dispatch. + if verb == "extract": + # Pure read: copy the model-supplied record so the caller can't mutate + # the action. No page write (acceptance #3). + return ActionResult.success("extract", record=dict(action["data"])) + if verb == "done": + # Distinctly-flagged terminal result (acceptance #3): terminal=True and + # no other verb sets it. No page call. + return ActionResult.success( + "done", + terminal=True, + detail={"status": action["status"], "note": action.get("note")}, + ) + + # Ref-addressed verbs: resolve against the current snapshot first; a + # stale/unknown ref fails before the page is touched (acceptance #2). + if verb in _REF_VERBS: + ref = action["ref"] + if resolve_ref(snapshot, ref) is None: + return ActionResult.failure(verb, f"stale/unknown ref: {ref!r}") + + try: + if verb == "navigate": + url = action["url"] + await page.goto(url) + return ActionResult.success("navigate", detail={"url": url}) + if verb == "click": + ref = action["ref"] + await page.click(ref) + return ActionResult.success("click", detail={"ref": ref}) + if verb == "type": + ref = action["ref"] + submit = bool(action.get("submit", False)) + await page.type(ref, action["text"], submit=submit) + return ActionResult.success( + "type", detail={"ref": ref, "submit": submit} + ) + if verb == "select": + ref = action["ref"] + await page.select(ref, action["value"]) + return ActionResult.success("select", detail={"ref": ref}) + if verb == "scroll": + direction = action["dir"] + await page.scroll(direction) + return ActionResult.success("scroll", detail={"dir": direction}) + except Exception as exc: # page-op failure → structured error, never raise + return ActionResult.failure(verb, f"page op failed: {exc}") + + # Unreachable: validate_action already rejected anything outside the fixed + # set. Kept as a defensive structured failure rather than a silent None. + return ActionResult.failure(verb, f"unhandled verb: {verb!r}") diff --git a/backend/skills/correction.py b/backend/skills/correction.py new file mode 100644 index 00000000..75f93cb1 --- /dev/null +++ b/backend/skills/correction.py @@ -0,0 +1,142 @@ +"""The **correct** leg — re-distill a failing skill from its execution trace. + +ADR-0003 **D7**: *correction is re-distillation, never a hand-patch.* When a skill +fails (or a human triggers it from the dock), the failing ``journey_trace_v1`` +trace(s) plus the current SKILL.md are fed back through the **same** distiller +(:func:`backend.skills.distill.distill_trace`) that produced version *n*, and the +result becomes version *n+1*: + + * ``version`` is bumped by exactly 1, + * one ``evidence`` entry (``event="corrected"``) is appended, + * ``skill_md`` / ``elements`` / ``distill_model`` / ``source_trace`` are + **replaced** from :func:`backend.skills.distill.to_skill_fields` — no field is + set by hand. The only manual mutations are the version bump and the evidence + append (the closed-loop bookkeeping the :class:`~backend.models.skill.Skill` + model exists for). + +Per **D8**, v1 re-distill is **human-triggered only** (endpoint / dock). This +module exposes the *service*; it never wires an automatic "N consecutive fails → +re-distill" policy (that is v2). +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.models.provider import ModelProvider +from backend.models.skill import Skill +from backend.skills.distill import ( + _DEFAULT_PROVIDER, + distill_trace, + provider_from_model, + to_skill_fields, +) + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +async def resolve_distill_provider(session: AsyncSession) -> dict[str, Any]: + """Resolve the distill provider config the same way the run path does. + + Mirrors :func:`backend.pipeline.runner.run_collection_pipeline` / + ``api.v1.chat._pick_provider``: the first **enabled** :class:`ModelProvider` + ordered by ``created_at`` (configure-once, just-works), mapped through + :func:`provider_from_model`. Falls back to + :data:`backend.skills.distill._DEFAULT_PROVIDER` when none is configured. + """ + result = await session.execute( + select(ModelProvider) + .where(ModelProvider.enabled.is_(True)) + .order_by(ModelProvider.created_at.asc()) + ) + mp = result.scalars().first() + if mp is not None: + return provider_from_model(mp) + return dict(_DEFAULT_PROVIDER) + + +async def re_distill( + session: AsyncSession, + skill: Skill, + traces: dict[str, Any] | list[dict[str, Any]], + provider: dict[str, Any] | None = None, +) -> Skill: + """Re-distill ``skill`` from its failing trace(s) into version *n+1*. + + Parameters + ---------- + session: + An open :class:`AsyncSession`; this function commits it. + skill: + The **existing** :class:`Skill` row to correct (loaded by the caller). + traces: + One ``journey_trace_v1`` dict, or a list of them. v1 keeps it simple: + the most recent trace is distilled (the list's last entry). The caller + (endpoint / dock) passes the failing trace inline. + provider: + Optional distill-provider config override. When ``None``, resolved via + :func:`resolve_distill_provider` (first enabled ModelProvider → default). + + Returns + ------- + Skill + The same row, now at version *n+1* with ``skill_md`` / ``elements`` + replaced from the fresh distillation and one appended ``evidence`` entry. + + Notes + ----- + Hard rule (ADR-0003 D7): ``skill_md`` / ``elements`` come **only** from + :func:`to_skill_fields`. The only hand-set fields are ``version`` (+1) and + the appended ``evidence`` entry. + """ + if isinstance(traces, dict): + trace = traces + else: + if not traces: + raise ValueError("re_distill requires at least one trace") + trace = traces[-1] # v1: distill the most recent failing trace + + if provider is None: + provider = await resolve_distill_provider(session) + + from_version = skill.version + + # Re-distill through the same kernel that produced version n (D7). + spec = await distill_trace(trace, provider) + fields = to_skill_fields(spec) + + # Replace body wholesale from the distilled fields — NO hand-patching. + skill.skill_md = fields["skill_md"] + skill.elements = dict(fields["elements"]) # reassign for JSON change-tracking + skill.distill_model = fields["distill_model"] + skill.source_trace = fields["source_trace"] + + # The only manual mutations: version bump + evidence append (closed loop). + skill.version = from_version + 1 + evidence = list(skill.evidence or []) + evidence.append( + { + "event": "corrected", + "from_version": from_version, + "to_version": skill.version, + "trace_id": trace.get("trace_id"), + "at": _now_iso(), + } + ) + skill.evidence = evidence # reassign so SQLAlchemy detects the JSON mutation + + await session.commit() + logger.info( + "re_distill | skill=%s %s -> v%s model=%s trace=%s", + skill.id, from_version, skill.version, skill.distill_model, trace.get("trace_id"), + ) + return skill diff --git a/backend/skills/distill.py b/backend/skills/distill.py new file mode 100644 index 00000000..2cdf205e --- /dev/null +++ b/backend/skills/distill.py @@ -0,0 +1,209 @@ +"""Distill kernel — trajectory → reusable skill spec. + +Moved from the validated BrowserBC path-B spike (STEP 1). The distillation +prompt and JSON-extraction logic are unchanged (that is the "已验证" part); +only the I/O surface is adapted to opencli-admin: + + * async httpx instead of blocking urllib, + * driven by a provider config dict (sourced from ModelProvider) instead of + hardcoded Ollama env vars, + * returns the distilled spec instead of writing files — the caller (pipeline + distill step) persists it to the Skill model. + +The 9 elements extracted (see SYSTEM prompt): + 1 general pattern (-> scope) 2 entry preconditions + 3 generalized procedure 4 milestones + 5 terminal/exit conditions 6 false terminal states + 7 failure modes + recovery 8 anti-drift boundaries + 9 red lines +""" + +import json +import re +from typing import TYPE_CHECKING, Any + +import httpx + +if TYPE_CHECKING: + from backend.models.provider import ModelProvider + +# The 9 elements the distiller extracts from a journey_trace_v1 trace. +SYSTEM = """你是技能蒸馏器。输入是一次人类浏览器操作轨迹(journey_trace_v1)。 +把它蒸馏成一份**可跨同类任务复用**的技能卡,提取 9 要素: +1 general pattern(这类任务的通用模式) +2 entry preconditions(开始前必须成立的前提) +3 generalized procedure(泛化的分步流程,不写死坐标/具体值) +4 milestones(中途可验证的里程碑) +5 terminal/exit conditions(怎么算真做完) +6 false terminal states(看着做完其实没做完的陷阱) +7 failure modes + recovery(常见失败与恢复) +8 anti-drift boundaries(防止偏离任务意图的边界) +9 red lines(绝不能做的危险动作) + +只输出一个 JSON 对象,键固定为: +skill_name, scope, preconditions(数组), procedure(数组), milestones(数组), +terminal_conditions(数组), false_terminal_states(数组), recovery_policies(数组), +anti_drift_boundaries(数组), red_lines(数组), skill_md(字符串,完整 SKILL.md 正文,markdown)。 +不要输出 markdown 代码块包裹,不要解释,只要 JSON。""" + +# Keys of the structured 9-element spec stored on the Skill model. +ELEMENT_KEYS = ( + "preconditions", + "procedure", + "milestones", + "terminal_conditions", + "false_terminal_states", + "recovery_policies", + "anti_drift_boundaries", + "red_lines", +) + +_DEFAULT_PROVIDER: dict[str, Any] = { + "base_url": "http://localhost:11434/v1", + "model": "qwen3:4b", + "api_key": None, + "api_style": "openai", # openai | ollama + "timeout": 180, +} + + +def provider_from_model(mp: "ModelProvider") -> dict[str, Any]: + """Build a distill provider config from a saved ModelProvider row.""" + style = "ollama" if (mp.provider_type or "").lower() == "local" else "openai" + return { + "base_url": mp.base_url or _DEFAULT_PROVIDER["base_url"], + "model": mp.default_model or _DEFAULT_PROVIDER["model"], + "api_key": mp.api_key, + "api_style": style, + "timeout": _DEFAULT_PROVIDER["timeout"], + } + + +async def call_llm(system: str, user: str, provider: dict[str, Any]) -> str: + """One chat completion against the provider. Supports OpenAI-compatible + (/v1/chat/completions) and native Ollama (/api/chat) styles.""" + base_url = provider.get("base_url", _DEFAULT_PROVIDER["base_url"]) + model = provider.get("model", _DEFAULT_PROVIDER["model"]) + api_key = provider.get("api_key") + api_style = provider.get("api_style", "openai") + timeout = provider.get("timeout", _DEFAULT_PROVIDER["timeout"]) + + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": user + "\n\n/no_think"}, + ] + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + async with httpx.AsyncClient(timeout=timeout) as client: + if api_style == "ollama": + resp = await client.post( + base_url.rstrip("/") + "/api/chat", + json={"model": model, "messages": messages, "stream": False, + "options": {"temperature": 0.2}}, + headers=headers, + ) + resp.raise_for_status() + return resp.json()["message"]["content"] + resp = await client.post( + base_url.rstrip("/") + "/chat/completions", + json={"model": model, "messages": messages, "stream": False, + "temperature": 0.2}, + headers=headers, + ) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"] + + +def extract_json(text: str) -> dict: + """Pull the first balanced JSON object out of an LLM reply (strips + blocks and ``` fences first).""" + text = re.sub(r".*?", "", text, flags=re.DOTALL).strip() + text = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.MULTILINE).strip() + start = text.find("{") + if start < 0: + raise ValueError(f"no JSON object in LLM output: {text[:200]!r}") + depth = 0 + for i in range(start, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return json.loads(text[start:i + 1]) + raise ValueError("unbalanced JSON braces in LLM output") + + +def slug(x: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", (x or "").lower()).strip("-") or "unknown" + + +def assemble_skill_md(s: dict) -> str: + """Fallback SKILL.md body when the LLM did not return a `skill_md` string.""" + def block(title: str, items: Any) -> str: + if not items: + return "" + if isinstance(items, str): + return f"## {title}\n\n{items}\n\n" + return f"## {title}\n\n" + "".join(f"- {x}\n" for x in items) + "\n" + + md = f"# {s.get('skill_name', 'unnamed-skill')}\n\n" + md += (s.get("scope", "") + "\n\n") if s.get("scope") else "" + md += block("Preconditions", s.get("preconditions")) + md += block("Procedure", s.get("procedure")) + md += block("Milestones", s.get("milestones")) + md += block("Terminal conditions", s.get("terminal_conditions")) + md += block("False terminal states", s.get("false_terminal_states")) + md += block("Recovery", s.get("recovery_policies")) + md += block("Anti-drift boundaries", s.get("anti_drift_boundaries")) + md += block("Red lines", s.get("red_lines")) + return md + + +async def distill_trace(trace: dict, provider: dict[str, Any] | None = None) -> dict: + """Distill one journey_trace_v1 trace into a skill spec. + + Returns a dict ready to map onto the Skill model via `to_skill_fields`: + skill_name, scope, skill_md, , domain, capability, + source_trace, distill_model + Pure: performs no DB or filesystem writes. + """ + provider = {**_DEFAULT_PROVIDER, **(provider or {})} + domain = trace.get("summary", {}).get("domain") or "unknown" + + user = "轨迹 JSON:\n" + json.dumps(trace, ensure_ascii=False, indent=2) + raw = await call_llm(SYSTEM, user, provider) + spec = extract_json(raw) + + capability = slug(spec.get("skill_name") or trace.get("label")) + skill_md = spec.get("skill_md") or assemble_skill_md(spec) + if not skill_md.lstrip().startswith("---"): + fm = ( + f"---\nname: {slug(domain)}-{capability}\n" + f"description: {spec.get('scope', spec.get('skill_name', capability))}\n---\n\n" + ) + skill_md = fm + skill_md + + spec.update( + domain=domain, + capability=capability, + skill_md=skill_md, + source_trace=trace.get("trace_id"), + distill_model=provider.get("model"), + ) + return spec + + +def to_skill_fields(spec: dict) -> dict[str, Any]: + """Map a distilled spec onto Skill model column kwargs.""" + return { + "domain": spec.get("domain") or "unknown", + "capability": spec.get("capability") or slug(spec.get("skill_name")), + "name": spec.get("skill_name") or spec.get("capability") or "unnamed-skill", + "scope": spec.get("scope"), + "skill_md": spec.get("skill_md") or "", + "elements": {k: spec.get(k) or [] for k in ELEMENT_KEYS}, + "source_trace": spec.get("source_trace"), + "distill_model": spec.get("distill_model"), + } diff --git a/backend/skills/loop.py b/backend/skills/loop.py new file mode 100644 index 00000000..6e8a2946 --- /dev/null +++ b/backend/skills/loop.py @@ -0,0 +1,477 @@ +"""The cheap-model step loop — perceive → propose → act (ADR-0003 D6). + +This is the **brain** of the skill execute leg: it lets a small text model (e.g. +``qwen3:4b``) drive a real Chrome page **one action per step**. Each step + + 1. **perceives** the page via ``page.snapshot()`` (issue 01's + ``[{ref, role, name, value}]`` projection), + 2. builds the step **system prompt** from the SKILL.md 9 elements + that + snapshot (:func:`backend.skills.prompt.build_system_prompt`), + 3. asks the model for **exactly one** action — reusing the agent dock's + tool-calling harness (OpenAI ``tool_calls`` for normal models, the Qwen XML + ```` variant for ``qwable``-style models, both normalized via the + reused parsers from ``backend.api.v1.chat``), + 4. **validates** it against issue 02's verb schema + (``backend.skills.actions.validate_action``), + 5. **executes** it through issue 02's executor + (``backend.skills.actions.execute_action``), and + 6. feeds the ``action -> result`` back into the transcript, + +looping until the model emits ``done{}`` (validated against +``terminal_conditions`` / ``false_terminal_states`` — a ``done`` that trips a +false-terminal phrase is **rejected** and the loop continues) or a ``max_steps`` +cap is hit. + +**Scope boundary (issue 03 only).** The loop is *pure of the spine*: it does +**not** emit events, open a DB session, classify risk, gate writes, acquire a +browser-pool slot, or build a ``ChannelResult``. Every action auto-runs (the +risk/confirm gate is issue 04). It returns plain Python data (:class:`LoopResult`) +that issues 04/05/06 consume. Provider **resolution** is the caller's job — the +loop receives an already-bound ``model_call`` and the resolved ``model`` name. +""" + +import time +from dataclasses import asdict, dataclass, field +from typing import Any, Protocol, runtime_checkable + +from backend.skills import actions +from backend.skills.toolcall import ( + _is_xml_tool_model, + _parse_tool_use, + _safe_json, +) +from backend.skills.prompt import ( + SKILL_TOOLS, + SKILL_TOOLS_TEXT, + build_system_prompt, +) +from backend.skills.risk import ( + AWAITING_CONFIRM, + classify_action, + should_run, +) + +# Max steps before the loop gives up without a `done` (ADR-0003 D6: "~20"). +MAX_STEPS = 20 + +# How many prior (action -> result) turns to keep in the model transcript. The +# cheap model has ~32k ctx and each step re-sends the full snapshot, so the +# running history is bounded to the most recent turns to avoid blow-up. +_TRANSCRIPT_WINDOW = 12 + + +@runtime_checkable +class SkillPage(Protocol): + """Minimal page boundary the loop perceives through (issue 01 satisfies it). + + The loop only needs to *perceive*; it acts exclusively through issue 02's + executor (``actions.execute_action(page, snapshot, action)``), so this + Protocol stays thin. ``url`` is optional context for step records. + """ + + async def snapshot(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]: ... + + +@dataclass +class StepRecord: + """One ordered step of the loop. Plain/dict-able for issues 05 & 06. + + ``terminal_check`` is set only on a ``done`` step + (``accepted`` | ``rejected``). + """ + + index: int + verb: str | None + args: dict[str, Any] + target: Any = None + snapshot_digest: str = "" + result: dict[str, Any] | None = None + error: str | None = None + terminal_check: str | None = None + elapsed_ms: int = 0 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class LoopResult: + """Raw material issues 05 (ChannelResult) & 06 (journey_trace_v1) consume. + + ``outcome`` ∈ ``{done_success, done_failed, capped, error, awaiting_confirm}``. + ``steps`` is ordered; ``extracts`` accumulates ``extract`` payloads in order. + ``awaiting_confirm`` is set when the risk gate (issue 04) blocked a write in + headless mode; ``proposed_action`` is then the action the operator must + confirm (issue 05 lifts both onto ``ChannelResult.metadata``). + """ + + steps: list[StepRecord] = field(default_factory=list) + extracts: list[dict[str, Any]] = field(default_factory=list) + outcome: str = "error" + summary: dict[str, Any] = field(default_factory=dict) + awaiting_confirm: bool = False + proposed_action: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "steps": [s.to_dict() for s in self.steps], + "extracts": self.extracts, + "outcome": self.outcome, + "summary": self.summary, + "awaiting_confirm": self.awaiting_confirm, + "proposed_action": self.proposed_action, + } + + +def _digest_snapshot(snapshot: list[dict[str, Any]]) -> str: + """Tiny, bounded fingerprint of a snapshot for the step record.""" + if not snapshot: + return "0 elements" + refs = [str(el.get("ref")) for el in snapshot[:8]] + more = "..." if len(snapshot) > 8 else "" + return f"{len(snapshot)} elements [{','.join(refs)}{more}]" + + +def _normalize_reply(reply: Any, *, xml: bool) -> tuple[list[tuple[str, dict[str, Any]]], str]: + """Normalize a raw model reply into ``[(verb, args), ...]`` + raw content. + + Handles **both** tool-call shapes with the *same* parsers the agent dock + uses (reuse, not fork — ``backend.api.v1.chat``): + + * **XML path** (``xml=True``): parse ```` from message content + via ``_parse_tool_use`` (which strips nothing itself — the regex already + ignores surrounding text; ```` blocks don't match the tool regex). + * **OpenAI path**: read ``reply.choices[0].message.tool_calls`` exactly as + ``chat.chat()`` does, decoding each call's JSON args with ``_safe_json``. + + Returns the *ordered* list of parsed calls (the loop takes the first) and the + assistant ``content`` (for the transcript). + """ + message = reply.choices[0].message + content = getattr(message, "content", "") or "" + + if xml: + return _parse_tool_use(content), content + + calls: list[tuple[str, dict[str, Any]]] = [] + for tc in (getattr(message, "tool_calls", None) or []): + name = tc.function.name + calls.append((name, _safe_json(tc.function.arguments))) + return calls, content + + +def _check_done( + action: dict[str, Any], + snapshot: list[dict[str, Any]], + elements: dict | None, +) -> str: + """Validate a claimed ``done`` against the 9 elements (ADR-0003 D6). + + Conservative, NL-tolerant heuristic (the executor is a cheap model and the + conditions are free text): a ``done`` is **rejected** when its ``note`` or + the current page text trips any ``false_terminal_states`` phrase. Otherwise + accepted. Returns ``"accepted"`` | ``"rejected"``. Never trusts ``done`` + blindly, but stays permissive enough not to deadlock a correct completion. + """ + fts = (elements or {}).get("false_terminal_states") or [] + if not fts: + return "accepted" + + # Haystack: the model's own note + the visible names/values in the snapshot. + note = str(action.get("note") or "") + snap_text = " ".join( + f"{el.get('name', '')} {el.get('value', '')}" for el in (snapshot or []) + ) + haystack = (note + " " + snap_text).lower() + + for phrase in fts: + p = str(phrase).strip().lower() + if p and p in haystack: + return "rejected" + return "accepted" + + +def _outcome_for_done(status: Any) -> str: + """Map a ``done`` status onto the LoopResult outcome vocabulary.""" + return "done_success" if str(status).lower() == "success" else "done_failed" + + +async def run_skill_loop( + *, + page: SkillPage, + model_call: Any, + model: str = "qwen3:4b", + skill_md: str | None = None, + elements: dict | None = None, + task: str | None = None, + max_steps: int = MAX_STEPS, + skill: Any = None, + auto_confirm: bool = False, + run_id: str | None = None, + emit: Any = None, +) -> LoopResult: + """Run the perceive → propose → act loop until ``done`` or ``max_steps``. + + Parameters + ---------- + page: + A :class:`SkillPage` (issue 01) — the loop calls ``await page.snapshot()`` + to perceive; all *acting* goes through issue 02's executor. + model_call: + An ``async (messages, *, tools, model, xml) -> reply`` callable already + bound to the resolved provider (issue 05 binds it; the test scripts it). + ``reply`` is an OpenAI-chat-shaped object + (``reply.choices[0].message`` with ``.tool_calls`` / ``.content``). + model: + Resolved model name — only used to pick the OpenAI vs XML tool path via + ``_is_xml_tool_model`` (reused from the agent dock). + skill_md / elements: + The SKILL.md 9 elements — structured ``elements`` preferred, raw + ``skill_md`` as fallback (see :func:`build_system_prompt`). + task: + Optional task description injected into the prompt. + max_steps: + Cap before terminating with ``outcome="capped"`` (default :data:`MAX_STEPS`). + skill: + The ``Skill`` row / ``elements`` dict carrying ``red_lines``, passed to + :func:`backend.skills.risk.classify_action` (issue 04). ``None`` (the + default) means no red lines — only the generic high-risk pattern applies. + auto_confirm: + Risk-gate bypass (``DataSource.channel_config["auto_confirm"]``, default + ``False``). When ``True`` a confirm-required action runs unattended; when + ``False`` a blocked write aborts the headless loop at + :data:`~backend.skills.risk.AWAITING_CONFIRM`. + run_id: + Optional run id; when set, the gate emits a per-step ``awaiting_confirm`` + :class:`~backend.models.task.TaskRunEvent` via ``events.emit`` on a + block. Best-effort — ``None`` (e.g. unit tests) just skips the event. + + Returns + ------- + LoopResult + Ordered ``steps`` + accumulated ``extracts`` + ``outcome`` + ``summary``. + On a headless gate block: ``outcome="awaiting_confirm"``, + ``awaiting_confirm=True``, ``proposed_action=``. + """ + result = LoopResult() + xml = _is_xml_tool_model(model) + transcript: list[dict[str, Any]] = [] # running (assistant/user) turns + + index = 0 + while index < max_steps: + # 1. perceive + snapshot = await page.snapshot() + digest = _digest_snapshot(snapshot) + + # 2. build the step system prompt (9 elements + snapshot) + system = build_system_prompt( + skill_md=skill_md, + elements=elements, + snapshot=snapshot, + task=task, + step_index=index, + max_steps=max_steps, + ) + if xml: + system += SKILL_TOOLS_TEXT + messages = [{"role": "system", "content": system}, *transcript] + + # 3. ask the model for one action + started = time.monotonic() + try: + reply = await model_call( + messages, tools=None if xml else SKILL_TOOLS, model=model, xml=xml + ) + except Exception as exc: # provider error → record + terminate cleanly + result.steps.append( + StepRecord( + index=index, + verb=None, + args={}, + snapshot_digest=digest, + error=f"model call failed: {exc}", + elapsed_ms=int((time.monotonic() - started) * 1000), + ) + ) + result.outcome = "error" + result.summary = _summarize(result, index + 1) + return result + + calls, content = _normalize_reply(reply, xml=xml) + elapsed = int((time.monotonic() - started) * 1000) + + # No tool call → nudge the model and continue (don't crash a confused model). + if not calls: + result.steps.append( + StepRecord( + index=index, + verb=None, + args={}, + snapshot_digest=digest, + error="no tool call emitted", + elapsed_ms=elapsed, + ) + ) + _push(transcript, content, "No tool call detected. Emit EXACTLY ONE tool call.") + index += 1 + continue + + # One action/step is the contract: take the first, note any truncation. + verb, args = calls[0] + truncated = len(calls) > 1 + action = {"verb": verb, **(args or {})} + + # 4. validate against issue 02's schema + verr = actions.validate_action(action) + if verr is not None: + step = StepRecord( + index=index, + verb=verb, + args=dict(args or {}), + snapshot_digest=digest, + error=verr, + elapsed_ms=elapsed, + ) + if truncated: + step.error += " (extra tool calls ignored: one action/step)" + result.steps.append(step) + _push(transcript, content, f'error: {verr}') + index += 1 + continue + + # 5. done → validate the claimed completion; do NOT execute as a page op. + if verb == "done": + check = _check_done(action, snapshot, elements) + result.steps.append( + StepRecord( + index=index, + verb="done", + args=dict(args or {}), + target=args.get("status"), + snapshot_digest=digest, + result={"status": args.get("status"), "note": args.get("note")}, + terminal_check=check, + elapsed_ms=elapsed, + ) + ) + if check == "accepted": + result.outcome = _outcome_for_done(args.get("status")) + result.summary = _summarize(result, index + 1, final_status=args.get("status")) + return result + # rejected → feed the rejection back and keep going (don't stop). + _push( + transcript, + content, + 'rejected: a false_terminal_state applies; ' + "the task is not actually complete. Keep going.", + ) + index += 1 + continue + + # 6. risk gate (issue 04) — classify BEFORE execution; block writes in + # headless mode unless auto_confirm bypasses. Reads/nav/scroll/extract + # auto-run; red_lines / submit|pay|post|delete need confirm. + target_element = ( + actions.resolve_ref(snapshot, action["ref"]) + if action.get("ref") is not None + else None + ) + decision = classify_action(action, target_element, skill) + if not should_run(decision, auto_confirm): + # Headless v1: abort cleanly. Interactive synchronous resume (the dock + # round-trip) is issues 05/06; here the testable behavior is the abort + # + the awaiting_confirm signal surfaced for the channel to lift onto + # ChannelResult.metadata (and later runner Phase 4 → run.status). + if run_id and emit is not None: + await emit( + run_id, + AWAITING_CONFIRM, + f"awaiting confirm: {verb} ({decision.reason})", + level="warning", + detail={ + "action": action, + "decision": decision.to_dict(), + "matched_red_line": decision.matched_red_line, + }, + elapsed_ms=elapsed, + ) + result.steps.append( + StepRecord( + index=index, + verb=verb, + args=dict(args or {}), + target=_target_of(action), + snapshot_digest=digest, + result={"gate": "blocked", "decision": decision.to_dict()}, + error=f"awaiting_confirm: {decision.reason}", + elapsed_ms=elapsed, + ) + ) + result.outcome = AWAITING_CONFIRM + result.awaiting_confirm = True + result.proposed_action = action + result.summary = _summarize(result, index + 1) + return result + + # 7. execute via issue 02's executor (auto-run, or auto_confirm bypass) + exec_result = await actions.execute_action(page, snapshot, action) + + # 8. extract → accumulate (issue 05 surfaces these as ChannelResult.items) + if verb == "extract" and exec_result.ok and exec_result.record is not None: + result.extracts.append(exec_result.record) + + # 9. ordered step record + feed action -> result back into the transcript + step = StepRecord( + index=index, + verb=verb, + args=dict(args or {}), + target=_target_of(action), + snapshot_digest=digest, + result={"ok": exec_result.ok, "detail": exec_result.detail}, + error=exec_result.error, + elapsed_ms=elapsed, + ) + if truncated: + step.result["truncated_extra_calls"] = True + result.steps.append(step) + + feedback = ( + f"ok: {exec_result.detail}" if exec_result.ok else f"error: {exec_result.error}" + ) + _push(transcript, content, f'{feedback}') + index += 1 + + # 10. cap hit without done + result.outcome = "capped" + result.summary = _summarize(result, index) + return result + + +# ── transcript / summary helpers ─────────────────────────────────────────────── +def _push(transcript: list[dict[str, Any]], assistant: str, user: str) -> None: + """Append an assistant turn + its tool-result user turn; keep it bounded.""" + transcript.append({"role": "assistant", "content": assistant or ""}) + transcript.append({"role": "user", "content": user}) + # Bound the running history (window counts assistant+user pairs). + if len(transcript) > _TRANSCRIPT_WINDOW * 2: + del transcript[: len(transcript) - _TRANSCRIPT_WINDOW * 2] + + +def _target_of(action: dict[str, Any]) -> Any: + """Best-effort 'what this action addressed' for the step record.""" + for key in ("ref", "url", "dir"): + if key in action: + return action[key] + return None + + +def _summarize( + result: LoopResult, step_count: int, *, final_status: Any = None +) -> dict[str, Any]: + """Small forward-compatible summary for issues 05 & 06.""" + return { + "step_count": step_count, + "extract_count": len(result.extracts), + "outcome": result.outcome, + "final_status": final_status, + } diff --git a/backend/skills/page.py b/backend/skills/page.py new file mode 100644 index 00000000..9b0d22d1 --- /dev/null +++ b/backend/skills/page.py @@ -0,0 +1,142 @@ +"""CDP page wrapper — drive a browser_pool Chrome over Playwright (ADR-0003 D1). + +Connects **over CDP** (``chromium.connect_over_cdp(cdp_endpoint)``) to an +**already-running** Chrome supplied by ``backend.browser_pool`` — the same +substrate the opencli channel relies on. ``connect_over_cdp`` *attaches* to the +existing browser context, so a logged-in page (site cookies already present in +that Chrome) is reused; it does **not** launch a new browser. Local + LAN +endpoints only (ADR-0003 D1); driving NAT edge nodes via ``agent_server`` is v2. + +``SkillPage`` exposes only the raw page ops the fixed verb set (#02) calls — +``goto / click / type / select / scroll / inner_text / extract`` — all +``ref``-addressed (a ``ref`` is the ``N`` that ``perception.snapshot()`` wrote +as ``data-skill-ref="N"``). It makes **no** risk decisions and exposes **no** +model-facing ``evaluate(js)`` escape hatch (ADR-0003 D2/D3); the single internal +``evaluate`` (for ``scroll``) stays server-side and is never surfaced to the +model. + +The caller owns the pool-slot lifetime: pass in the endpoint string that +``browser_pool.get_pool().acquire(endpoint=...)`` yields; do **not** acquire the +slot inside ``SkillPage``. On close we drop the **CDP connection** (and stop the +Playwright driver) — we never close the underlying Chrome owned by the pool. +""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def _ref_selector(ref: str | int) -> str: + """CSS selector resolving an element strictly by its data-skill-ref. + + A ``ref`` is the ``N`` ``perception.snapshot()`` assigned as + ``data-skill-ref="N"``. Resolving strictly by that attribute means a stale + ref fails loudly (no element) rather than silently clicking the wrong one. + """ + return f'[data-skill-ref="{ref}"]' + + +class SkillPage: + """Thin async wrapper around a CDP-attached Playwright page. + + Holds the Playwright handle, the connected browser, and the active page. + Build it via :func:`open_skill_page`; use it as an async context manager so + the loop (#03) can ``async with open_skill_page(ep) as sp:``. + """ + + def __init__(self, pw: Any, browser: Any, page: Any) -> None: + self._pw = pw + self._browser = browser + self.page = page + + # ── lifecycle ───────────────────────────────────────────────────────── + async def __aenter__(self) -> "SkillPage": + return self + + async def __aexit__(self, *_exc: Any) -> None: + await self.aclose() + + async def aclose(self) -> None: + """Drop the CDP connection and stop the Playwright driver. + + We close the Playwright **connection** to the browser (for + ``connect_over_cdp`` this detaches the client; it does not terminate the + shared Chrome owned by the pool) and then stop the driver. Best-effort: + connection teardown never raises out of here. + """ + try: + if self._browser is not None: + await self._browser.close() + except Exception as exc: # pragma: no cover - teardown best-effort + logger.debug("SkillPage: browser connection close failed: %s", exc) + finally: + self._browser = None + try: + if self._pw is not None: + await self._pw.stop() + except Exception as exc: # pragma: no cover - teardown best-effort + logger.debug("SkillPage: playwright stop failed: %s", exc) + finally: + self._pw = None + + # ── raw page ops (the verb set #02 dispatches to) ───────────────────── + async def goto(self, url: str) -> None: + """Navigate to ``url`` and return when navigation settles.""" + await self.page.goto(url) + + async def click(self, ref: str | int) -> None: + """Click the element tagged ``data-skill-ref=""``.""" + await self.page.locator(_ref_selector(ref)).click() + + async def type(self, ref: str | int, text: str, submit: bool = False) -> None: + """Fill the ``ref`` element with ``text``; optionally press Enter.""" + locator = self.page.locator(_ref_selector(ref)) + await locator.fill(text) + if submit: + await locator.press("Enter") + + async def select(self, ref: str | int, value: str) -> None: + """Select ``value`` in the ``ref`` `` element addressed by ref.", + "parameters": { + "type": "object", + "properties": { + "ref": {"type": "string", "description": "The ref of the .\n" + "- scroll(dir): scroll one viewport, dir is \"up\" or \"down\".\n" + "- extract(data): emit a structured record (object) of data read from the page.\n" + "- done(status, note): finish; status is \"success\"|\"failed\"|\"paused\". " + "Call done ONLY when a terminal_condition is met, NEVER in a false_terminal_state.\n" + 'To act, output strictly this XML and nothing else: ' + '{json args}\n' + "Address elements by their ref from the snapshot. Do not use JS or any verb " + "outside this set. Do not wrap output in markdown code fences." +) + + +# ── System prompt builder ────────────────────────────────────────────────────── +# The 9-element sections the step prompt foregrounds (ADR-0003 D6). procedure / +# milestones / terminal_conditions / false_terminal_states / red_lines are the +# loop-control elements; the prompt MUST contain them (acceptance #1). +_PROMPT_ELEMENT_SECTIONS: tuple[tuple[str, str], ...] = ( + ("procedure", "Procedure (generalized steps)"), + ("milestones", "Milestones (mid-way checkpoints)"), + ("terminal_conditions", "Terminal conditions (how you know it's truly done)"), + ( + "false_terminal_states", + "False terminal states (TRAPS — looks done but is NOT; never `done` here)", + ), + ("red_lines", "Red lines (never do these)"), +) + + +def _render_element(value: Any) -> str: + """Render one 9-element value (list[str] | str | None) as prompt lines.""" + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, (list, tuple)): + items = [str(x).strip() for x in value if str(x).strip()] + return "\n".join(f"- {x}" for x in items) + return str(value).strip() + + +def _render_snapshot(snapshot: list[dict[str, Any]]) -> str: + """Render the [{ref, role, name, value}] snapshot compactly, one line each. + + ``# "" = `` (value omitted when empty). Already + token-bounded by perception (issue 01) — do not re-expand here. + """ + if not snapshot: + return "(no interactive elements detected)" + lines: list[str] = [] + for el in snapshot: + ref = el.get("ref", "") + role = str(el.get("role", "") or "") + name = str(el.get("name", "") or "") + value = str(el.get("value", "") or "") + line = f'#{ref} {role} "{name}"' + if value: + line += f" = {value}" + lines.append(line) + return "\n".join(lines) + + +def build_system_prompt( + *, + skill_md: str | None, + elements: dict | None, + snapshot: list[dict[str, Any]], + task: str | None, + step_index: int, + max_steps: int, +) -> str: + """Build the per-step system prompt from the 9 elements + current snapshot. + + The skill spec arrives two ways (a Skill row carries both): structured + ``elements`` (the ``Skill.elements`` JSON — keys per + ``backend.skills.distill.ELEMENT_KEYS``) and/or the raw ``skill_md``. Prefer + structured ``elements`` so each loop-control section is addressable; when no + usable ``elements`` are given, fall back to embedding ``skill_md`` verbatim + (it already contains the same sections). The returned prompt always: + + * foregrounds ``procedure``, ``milestones``, ``terminal_conditions``, + ``false_terminal_states`` and ``red_lines`` (acceptance #1), + * states the loop contract (exactly one action/step; ``done`` only on a + terminal condition; ``false_terminal_states`` are traps; address by + ``ref``; no JS / no verbs outside the set), and + * renders the current snapshot so every element's ``ref`` appears. + """ + parts: list[str] = [] + parts.append( + "You are a careful browser-automation agent driving a real Chrome page " + "to accomplish a task by following a distilled skill." + ) + if task: + parts.append(f"Task:\n{task}") + + # 9-element body: structured sections when available, else raw skill_md. + rendered_sections: list[str] = [] + if elements: + for key, label in _PROMPT_ELEMENT_SECTIONS: + body = _render_element(elements.get(key)) + if body: + rendered_sections.append(f"## {label}\n{body}") + if rendered_sections: + parts.append("Skill card (follow it):\n\n" + "\n\n".join(rendered_sections)) + elif skill_md: + # Degrade gracefully: the raw card already carries the same sections. + parts.append("Skill card (SKILL.md — follow it):\n\n" + skill_md.strip()) + + # Loop contract (ADR-0003 D6). + parts.append( + "Loop contract:\n" + "- Emit EXACTLY ONE tool call (one action) per step.\n" + "- Call `done` ONLY when a terminal condition is met.\n" + "- `false_terminal_states` are traps — do NOT `done` when one applies.\n" + "- Address elements by their `ref` from the snapshot below.\n" + "- Use only the provided verbs. No JavaScript, no actions outside the verb set.\n" + f"- This is step {step_index + 1} of at most {max_steps}." + ) + + # Current perception (renders every ref — acceptance #1). + parts.append( + "Current page (interactive elements, addressed by ref):\n" + + _render_snapshot(snapshot) + ) + + return "\n\n".join(parts) diff --git a/backend/skills/risk.py b/backend/skills/risk.py new file mode 100644 index 00000000..4dd3e294 --- /dev/null +++ b/backend/skills/risk.py @@ -0,0 +1,238 @@ +"""Risk-tiered confirm classifier + gate (ADR-0003 D4, PRD §4 D4 / §7). + +The **safety spine** of the skill execute loop. A cheap text model drives a real +Chrome page one action per step (issue 03); before any action reaches issue 02's +``execute_action`` it passes through this gate. Reads / navigation / scroll / +extract auto-run; an action that matches the skill's ``red_lines`` **or** the +generic high-risk verb pattern (``submit | pay | post | delete``) needs confirm — +"写前确认是硬底线". A source may opt a trusted skill into unattended running with +``channel_config.auto_confirm = true`` (default **off**). + +Design constraints (do not relitigate — ADR-0003): + + * **Pure.** :func:`classify_action` takes plain data (``action`` dict, the + resolved ``element`` snapshot entry, and the ``Skill``/dict carrying + ``red_lines``) and returns a :class:`RiskDecision`. **No DB session, no + Playwright, no events, no I/O** — that is what makes the classifier unit + testable with ``-m "not live"`` and no browser. + * **Conservative.** The dangerous failure mode is a *false negative* (a write + mis-classified as auto-run = a silent submit/pay/post). On any ambiguity the + classifier defaults to ``needs_confirm=True`` (``reason="ambiguous-default- + confirm"``). + * **``red_lines`` are authoritative** over the generic verb pattern: an + ``extract`` / ``navigate`` named in a red line still needs confirm. + +The :data:`AWAITING_CONFIRM` string is the single source of truth for the new +paused run status (PRD §5/§7: ``TaskRun.status`` is free-text ``String(50)`` so a +typo won't be caught by the DB — define it once). ``loop.py`` (and the runner's +Phase-4 status write in issue 05) import this constant, never the literal. +""" + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +# ── Centralized run-status / metadata key ────────────────────────────────────── +# The new paused run status AND the ChannelResult.metadata key that signals it up +# the spine (PRD §5: metadata["awaiting_confirm"] -> PipelineResult.metadata -> +# runner Phase 4). Defined once; imported by loop.py / runner (issue 05). +AWAITING_CONFIRM = "awaiting_confirm" + +# The companion metadata key carrying the action the operator must confirm. +PROPOSED_ACTION = "proposed_action" + +# Generic high-risk verbs (ADR-0003 D4). Matched against the action verb AND the +# target element's name/role/value, case-insensitive, substring/word-ish. +HIGH_RISK_VERBS: tuple[str, ...] = ("submit", "pay", "post", "delete") + +# Verbs that are inherently safe regardless of target (ADR-0003 D4: reads / +# navigation / scroll / extract auto-run). ``done`` is a control verb, not a +# page op, and is always safe. +AUTO_RUN_VERBS: tuple[str, ...] = ("navigate", "scroll", "extract", "done") + +# Write-ish verbs that address a DOM element (ADR-0003 D3 ref verbs). These are +# the only verbs the generic high-risk pattern applies to, and the only verbs for +# which an unresolvable element (``element is None``) is treated as ambiguous. +WRITE_VERBS: frozenset[str] = frozenset({"click", "type", "select"}) + + +class RiskTier(StrEnum): + """Two tiers (ADR-0003 D4). ``StrEnum`` so the value IS the string ('auto'/ + 'confirm') for events/JSON while keeping enum identity for the gate.""" + + AUTO = "auto" # read / navigate / scroll / extract — runs unattended + CONFIRM = "confirm" # write / high-risk / ambiguous — needs confirm + + +@dataclass(frozen=True) +class RiskDecision: + """Outcome of classifying one action. Plain/immutable; dict-able for events.""" + + tier: RiskTier + needs_confirm: bool + reason: str # why (for the event detail + tests) + matched_red_line: str | None = None # the red line that fired (step 1 only) + + def to_dict(self) -> dict[str, Any]: + return { + "tier": self.tier.value, + "needs_confirm": self.needs_confirm, + "reason": self.reason, + "matched_red_line": self.matched_red_line, + } + + +def _red_lines_of(skill: Any) -> list[str]: + """Read ``red_lines`` from a Skill row, its ``elements`` dict, or a plain dict. + + Accepts (so the classifier is testable without a DB row): + * a ``Skill``-like object with an ``elements`` mapping, + * a plain ``elements`` dict (``{"red_lines": [...]}``), + * a dict that *is* the skill and nests ``elements``, + * ``None`` → no red lines. + Always returns a list of non-empty strings. + """ + if skill is None: + return [] + + elements: Any = None + if isinstance(skill, dict): + # Either the elements dict itself, or a skill-shaped dict nesting it. + elements = skill.get("elements", skill) + else: + elements = getattr(skill, "elements", None) + + red_lines: Any = None + if isinstance(elements, dict): + red_lines = elements.get("red_lines") + if red_lines is None and isinstance(skill, dict): + red_lines = skill.get("red_lines") + + if not red_lines: + return [] + if isinstance(red_lines, str): + red_lines = [red_lines] + return [str(x).strip() for x in red_lines if str(x).strip()] + + +def _action_haystack(action: dict[str, Any], element: dict[str, Any] | None) -> str: + """Lowercased text blob to match risk tokens against. + + Combines the action verb + the model-supplied free-text fields (``text``, + ``data``, ``url``, ``value``, ``note``, ``status``) with the resolved + element's ``name`` / ``role`` / ``value``. This is what both the red-line + match and the generic high-risk pattern search. + """ + parts: list[str] = [] + if isinstance(action, dict): + for key in ("verb", "text", "url", "value", "note", "status"): + v = action.get(key) + if v: + parts.append(str(v)) + data = action.get("data") + if data: + parts.append(str(data)) + if isinstance(element, dict): + for key in ("name", "role", "value"): + v = element.get(key) + if v: + parts.append(str(v)) + return " ".join(parts).lower() + + +def classify_action( + action: dict[str, Any], element: dict[str, Any] | None, skill: Any +) -> RiskDecision: + """Classify one action into a :class:`RiskDecision`. Pure; never raises. + + Decision order (this order is the contract — tests assert it): + + 1. **``red_lines`` first and authoritative.** If the action (verb + target + element name/role/value + the model's free-text args, lowercased) contains + any red-line phrase → ``CONFIRM`` with ``matched_red_line`` set. **Wins + even when the verb would otherwise auto-run** (e.g. an ``extract`` named in + a red line) — acceptance criterion 3. + 2. **Generic high-risk pattern.** For a write verb (``click`` / ``type`` / + ``select``): a ``type{...,submit:true}`` (the submit flag is a write + signal regardless of element name), OR the verb token / element + name/role/value containing a :data:`HIGH_RISK_VERBS` token + (``submit|pay|post|delete``) → ``CONFIRM``. + 3. **Auto-run tiers.** ``navigate`` / ``scroll`` / ``extract`` / ``done``, and + any plain read-style ``click`` / ``select`` that matched nothing above → + ``AUTO``. **Any read is auto.** + 4. **Ambiguous default ⇒ confirm.** Unrecognized verb, OR a write verb with + an unresolvable target (``element is None``) → ``CONFIRM`` with + ``reason="ambiguous-default-confirm"``. + """ + if not isinstance(action, dict): + return RiskDecision( + RiskTier.CONFIRM, True, reason="ambiguous-default-confirm" + ) + + verb = str(action.get("verb") or "").strip().lower() + haystack = _action_haystack(action, element) + + # 1. red_lines — authoritative, even over auto-run verbs. + for line in _red_lines_of(skill): + token = line.lower() + if token and token in haystack: + return RiskDecision( + RiskTier.CONFIRM, + True, + reason="red-line", + matched_red_line=line, + ) + + is_write_verb = verb in WRITE_VERBS + + # 2. generic high-risk pattern (only meaningful for write verbs). + if is_write_verb: + # `type{...,submit:true}` is a write regardless of element name. + if verb == "type" and bool(action.get("submit", False)): + return RiskDecision( + RiskTier.CONFIRM, True, reason="submit-flag" + ) + for token in HIGH_RISK_VERBS: + if token in haystack: + return RiskDecision( + RiskTier.CONFIRM, True, reason=f"high-risk-verb:{token}" + ) + + # 4a. write verb with no resolvable target → ambiguous → confirm. + if is_write_verb and element is None: + return RiskDecision( + RiskTier.CONFIRM, True, reason="ambiguous-default-confirm" + ) + + # 3. auto-run tiers: known-safe verbs, and plain reads. + if verb in AUTO_RUN_VERBS or is_write_verb: + return RiskDecision(RiskTier.AUTO, False, reason=f"auto:{verb}") + + # 4b. anything else (unknown verb) → ambiguous → confirm. + return RiskDecision( + RiskTier.CONFIRM, True, reason="ambiguous-default-confirm" + ) + + +def should_run(decision: RiskDecision, auto_confirm: bool) -> bool: + """Gate decision: may this action run *now* without a human confirm? + + ``True`` → run it (auto-run tier, or ``auto_confirm`` bypasses the confirm). + ``False`` → block: in headless v1 the loop aborts at :data:`AWAITING_CONFIRM` + (interactive synchronous resume is issues 05/06). Pure — no browser needed, + which is what makes acceptance criterion 5 testable in isolation. + """ + if not decision.needs_confirm: + return True + return bool(auto_confirm) + + +def awaiting_confirm_metadata(action: dict[str, Any]) -> dict[str, Any]: + """The additive ``ChannelResult.metadata`` contract for a blocked action. + + ``{AWAITING_CONFIRM: True, PROPOSED_ACTION: }`` — rides + ``ChannelResult.metadata`` → ``PipelineResult.metadata`` → runner Phase 4 + (issue 05 reads it and sets ``run.status = AWAITING_CONFIRM``). Keyed by the + centralized constants, never inlined literals. + """ + return {AWAITING_CONFIRM: True, PROPOSED_ACTION: dict(action)} diff --git a/backend/skills/toolcall.py b/backend/skills/toolcall.py new file mode 100644 index 00000000..6fd9f57c --- /dev/null +++ b/backend/skills/toolcall.py @@ -0,0 +1,50 @@ +"""LLM tool-call normalization — pure, shared, dependency-free. + +Parses a chat model's tool-call output in both shapes the stack supports: native +OpenAI ``tool_calls`` and the XML ```` fallback some local models +(Qwen-style) emit as plain message content. These helpers are *pure* — no I/O, no +DB, no FastAPI — and live in the skills package so the execute core +(:mod:`backend.skills.loop`) is self-contained and reusable. + +Owning them here (rather than in the agent dock at ``backend.api.v1.chat``) breaks +the old ``skills.loop → api.v1.chat`` import cycle: the reusable skill core no +longer drags the dock's HTTP layer in. The dock can import the same helpers from +here to stay DRY (single source of truth for tool-call parsing). +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +# Some local models can't emit OpenAI ``tool_calls``; they return tool calls as +# XML in the message content. Callers describe the tools in the prompt as text +# and parse the XML themselves via the helpers below. +XML_TOOL_MODELS = ("qwable",) + +# matches both (self-closing) and +# {json} +_TOOL_USE_RE = re.compile( + r']*?(?:/\s*>|>\s*(\{.*?\}|)\s*)', re.DOTALL +) + + +def _is_xml_tool_model(model: str) -> bool: + m = model.lower() + return any(k in m for k in XML_TOOL_MODELS) + + +def _parse_tool_use(content: str) -> list[tuple[str, dict[str, Any]]]: + calls: list[tuple[str, dict[str, Any]]] = [] + for match in _TOOL_USE_RE.finditer(content or ""): + calls.append((match.group(1), _safe_json(match.group(2) or "{}"))) + return calls + + +def _safe_json(raw: str) -> dict[str, Any]: + try: + value = json.loads(raw or "{}") + return value if isinstance(value, dict) else {} + except json.JSONDecodeError: + return {} diff --git a/backend/skills/trace.py b/backend/skills/trace.py new file mode 100644 index 00000000..9aa13f9a --- /dev/null +++ b/backend/skills/trace.py @@ -0,0 +1,193 @@ +"""The shared ``journey_trace_v1`` shape (ADR-0003 D6, D7). + +Both legs of the closed loop must target **one** trace shape so they feed the +*same* distiller: + + * the human **record** leg ("录这站") — a separate TODO (PRD §1, §7) — turns a + demonstration into the *first* ``journey_trace_v1``, + * the **execute → correct** leg (this issue) assembles a trace from an execute + run's step events + outcome. + +:func:`assemble_trace` is that single builder. It is **forward-compatible** with +:func:`backend.skills.distill.distill_trace`, which reads exactly three keys — +``trace["summary"]["domain"]``, ``trace["label"]``, ``trace["trace_id"]`` — and +ignores everything else, so the extra ``schema`` / ``steps`` / ``outcome`` keys +this shape carries do not perturb distillation. + +:func:`self_eval` is a small **pure** function comparing a run's outcome against +the skill's ``terminal_conditions`` / ``milestones`` (read from +``skill.elements``; keys per :data:`backend.skills.distill.ELEMENT_KEYS`). The +dict it returns is what the channel appends to ``skills.evidence`` (the +closed-loop log the :class:`~backend.models.skill.Skill` model is built for). +Neither function touches the DB or the network. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +# The shape tag both legs share. Bump only on an incompatible shape change. +TRACE_SCHEMA = "journey_trace_v1" + +# Loop outcome (LoopResult.outcome) → trace/self-eval status vocabulary. +# ``done_success`` is the only "passed" terminal state; ``awaiting_confirm`` maps +# to ``paused`` (the run stopped at a confirm gate, neither done nor failed). +_OUTCOME_STATUS = { + "done_success": "success", + "done_failed": "failed", + "capped": "failed", + "error": "failed", + "awaiting_confirm": "paused", +} + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def assemble_trace( + step_events: list[dict[str, Any]], + outcome: dict[str, Any], + *, + domain: str, + label: str, + trace_id: str, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Assemble a ``journey_trace_v1`` from a run's step events + outcome. + + Parameters + ---------- + step_events: + One dict per loop step (built from the loop's own ordered step records — + the channel accumulates them as it emits; we do **not** re-query + ``TaskRunEvent`` rows). Each entry carries at least the action verb, + the ref/target it addressed, a snapshot digest, the result, and timing. + outcome: + ``{"status": "success"|"failed"|"paused", "milestones_hit": [...], + "terminal_check": , ...}`` — the run's terminal summary. + domain / label / trace_id: + The three keys :func:`distill_trace` reads. ``domain`` lands at + ``summary.domain``; ``label`` is the capability-slug fallback; + ``trace_id`` becomes the distilled spec's ``source_trace``. + extra: + Optional extra ``summary`` fields (merged into ``summary``). Distiller + ignores unknown keys, so this stays forward-compatible. + + Returns + ------- + dict + ``{schema, trace_id, label, summary{domain, ...}, steps[], outcome}``. + At least one ``steps`` entry per loop step; an ``outcome`` block always + present. Round-trips through :func:`distill_trace` unchanged (it needs + only ``summary.domain`` / ``label`` / ``trace_id``). + """ + return { + "schema": TRACE_SCHEMA, + "trace_id": trace_id, + "label": label, + "summary": {"domain": domain, **(extra or {})}, + "steps": list(step_events), + "outcome": outcome, + } + + +def outcome_from_loop( + loop_outcome: str, + *, + milestones_hit: list[Any] | None = None, + terminal_check: Any = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the trace ``outcome`` block from a :class:`LoopResult` outcome. + + Maps the loop's outcome vocabulary (``done_success`` / ``done_failed`` / + ``capped`` / ``error`` / ``awaiting_confirm``) onto the trace status + (``success`` / ``failed`` / ``paused``) shared by both legs. + """ + return { + "status": _OUTCOME_STATUS.get(loop_outcome, "failed"), + "loop_outcome": loop_outcome, + "milestones_hit": list(milestones_hit or []), + "terminal_check": terminal_check, + **(extra or {}), + } + + +def _as_list(value: Any) -> list[Any]: + """Coerce a possibly-None / scalar elements field into a list.""" + if value is None: + return [] + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + +def _skill_elements(skill: Any) -> dict[str, Any]: + """Best-effort pull of the structured 9-element dict from a Skill / dict. + + Accepts a :class:`~backend.models.skill.Skill` row (reads ``.elements``) or a + bare ``elements``-shaped dict (inline-skill case). Returns ``{}`` when no + structured elements are resolvable. + """ + if skill is None: + return {} + elements = getattr(skill, "elements", None) + if isinstance(elements, dict): + return elements + # Inline case: ``skill`` may itself be the elements dict (channel passes + # ``elements or config``); only treat it as such if it looks like one. + if isinstance(skill, dict): + if "elements" in skill and isinstance(skill["elements"], dict): + return skill["elements"] + return skill + return {} + + +def self_eval(outcome: dict[str, Any], skill: Any) -> dict[str, Any]: + """Compare a run outcome against the skill's terminal/milestone conditions. + + Pure. Returns the evidence entry appended to ``skills.evidence``:: + + {"event": "executed", "passed": bool, "milestones_hit": [...], + "terminal_met": bool, "outcome": "...", "trace_id": "...", "at": } + + ``passed`` is the conjunction of "the run terminated successfully" and "no + declared terminal condition was violated". With no declared + ``terminal_conditions``, ``terminal_met`` falls back to the run's own + ``status == "success"`` (we can't contradict a clean ``done`` we have no + rule to judge). ``milestones_hit`` echoes the outcome's reported hits, + bounded to those the skill actually declares when it declares any. + """ + elements = _skill_elements(skill) + declared_terminals = _as_list(elements.get("terminal_conditions")) + declared_milestones = _as_list(elements.get("milestones")) + + status = str(outcome.get("status") or "").lower() + succeeded = status == "success" + + reported_hits = _as_list(outcome.get("milestones_hit")) + if declared_milestones: + declared_set = {str(m) for m in declared_milestones} + milestones_hit = [m for m in reported_hits if str(m) in declared_set] + else: + milestones_hit = reported_hits + + if declared_terminals: + # The loop validates a claimed ``done`` against terminal/false-terminal + # conditions (see loop._check_done); a successful terminal status means + # that validation passed. A non-success status never meets terminals. + terminal_met = succeeded + else: + terminal_met = succeeded + + return { + "event": "executed", + "passed": bool(succeeded and terminal_met), + "milestones_hit": milestones_hit, + "terminal_met": bool(terminal_met), + "outcome": status or "unknown", + "trace_id": outcome.get("trace_id"), + "at": _now_iso(), + } diff --git a/chrome/extension-src/project.json b/chrome/extension-src/project.json new file mode 100644 index 00000000..42f21013 --- /dev/null +++ b/chrome/extension-src/project.json @@ -0,0 +1,44 @@ +{ + "name": "extension", + "root": "chrome/extension-src", + "sourceRoot": "chrome/extension-src", + "projectType": "application", + "targets": { + "dev": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm run dev" + ], + "cwd": "chrome/extension-src" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm run build" + ], + "cwd": "chrome/extension-src" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm run typecheck" + ], + "cwd": "chrome/extension-src" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm run lint --if-present" + ], + "cwd": "chrome/extension-src" + } + } + } +} diff --git a/docker-compose.build.yml b/docker-compose.build.yml index f476c9a5..d36a0f79 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -1,11 +1,12 @@ -# Local build override — adds build: sections so images are built from source. +# Local build override: builds backend and agent images from source. +# The default docker-compose.yml already builds the Vite frontend from ./frontend. # # Usage: -# docker compose -f docker-compose.yml -f docker-compose.build.yml up --build +# docker compose -f docker-compose.yml -f docker-compose.build.yml up --build # # Or set an alias / shell function: -# alias dc-build='docker compose -f docker-compose.yml -f docker-compose.build.yml' -# dc-build up --build +# alias dc-build='docker compose -f docker-compose.yml -f docker-compose.build.yml' +# dc-build up --build x-backend-build: &backend-build build: @@ -16,9 +17,9 @@ x-agent-build: &agent-build build: context: . dockerfile: agent/Dockerfile - # INSTALL_CHROME=false (default) — ~200 MB image, connects to host Chrome. - # Set INSTALL_CHROME=true to embed Chromium + Xvfb (~1.2 GB), fully self-contained. args: + # INSTALL_CHROME=false (default): ~200 MB image, connects to host Chrome. + # Set INSTALL_CHROME=true to embed Chromium + Xvfb (~1.2 GB), fully self-contained. INSTALL_CHROME: ${INSTALL_CHROME:-false} services: @@ -34,11 +35,6 @@ services: agent-1: <<: *agent-build - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - - # Remote edge agent — same image, same default (no embedded Chrome). + # Remote edge agent: same image, same default (no embedded Chrome). agent: <<: *agent-build diff --git a/docker-compose.yml b/docker-compose.yml index e0ddaf93..bd7fb455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,8 @@ x-backend-common: &backend-common image: ${DOCKER_REGISTRY:-docker.io/}xjh1994/opencli-admin-api:${IMAGE_TAG:-0.3.6} env_file: - - .env + - path: .env + required: false volumes: - db_data:/data - ./backend:/app/backend @@ -137,7 +138,6 @@ services: # • macOS/Linux: open -a "Google Chrome" --args --remote-debugging-port=9222 # • Or start the Bridge daemon: node $(npm root -g)/@jackwener/opencli/dist/daemon.js agent-1: - container_name: agent-1 image: ${DOCKER_REGISTRY:-docker.io/}xjh1994/opencli-admin-agent:${IMAGE_TAG:-0.3.6}${CHROME_SUFFIX:-} environment: CENTRAL_API_URL: http://api:8000 @@ -205,38 +205,16 @@ services: # ── Frontend (nginx + built React) — production ─────────────────────────── frontend: - image: ${DOCKER_REGISTRY:-docker.io/}xjh1994/opencli-admin-frontend:${IMAGE_TAG:-0.3.6} - ports: - - "${FRONTEND_PORT:-8030}:80" - depends_on: - api: - condition: service_healthy - restart: unless-stopped - - # ── Next.js App Router Frontend (现代化前端) ───────────────────────────────── - # 使用: docker compose --profile nextjs up - web: - profiles: ["nextjs"] build: - context: ./apps/web + context: ./frontend dockerfile: Dockerfile + image: ${FRONTEND_IMAGE:-opencli-admin-frontend:local} ports: - - "${WEB_PORT:-3000}:3000" - environment: - # API 地址 (Docker 网络内) - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://api:8000} - # 可选: 自定义 API URL (浏览器访问) - # NEXT_PUBLIC_API_URL: http://localhost:8031 + - "${FRONTEND_PORT:-8030}:80" depends_on: api: condition: service_healthy restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s # ── ODP data plane (Rust hot path) — docker compose --profile odp up odp-ingest: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3ba8f18..9669c024 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,6 +7,13 @@ --- +## 当前实现基线(v0.4 前) + +- 生产前端主线是 `frontend/`:React + Vite + nginx Dockerfile。 +- `experiments/next-web/` 是 Next.js 实验壳,不参与默认 Docker、CI 或导航。 +- 默认 `docker-compose.yml` 会从 `./frontend` 构建前端镜像。 +- 本文后续关于 Next.js/Hono/Turborepo 的章节属于历史目标架构或迁移设想,不能覆盖当前实现事实。 + ## 1. 概述 ### 1.1 项目定位 @@ -264,10 +271,10 @@ odp-rs/ - PostgreSQL 写入 - Redis Streams 缓冲 -### 3.4 前端 (Next.js) +### 3.4 前端实验壳 (Next.js, 非生产主线) ``` -apps/web/ +experiments/next-web/ ├── src/ │ ├── app/ # App Router │ │ ├── (auth)/ # 认证路由组 diff --git a/docs/COLLECTION_OPERATIONS_CONSOLE.md b/docs/COLLECTION_OPERATIONS_CONSOLE.md new file mode 100644 index 00000000..09d3a99a --- /dev/null +++ b/docs/COLLECTION_OPERATIONS_CONSOLE.md @@ -0,0 +1,235 @@ +# Collection Operations Console + +状态:Draft +最后更新:2026-06-25 + +## 1. 目标 + +Collection Operations Console 是 OpenCLI Admin 面向操作者的主工作面。 + +它把采集运行视为需要捕获、分拣、归属、推进状态、实时观察和关闭的工作,而不是一张被动日志表。 + +目标不是做更大的 dashboard,也不是做 canvas-first workflow tool。目标是让带浏览器会话的采集工作在失败、过期、阻塞、噪声过大、产出质量不确定时更容易处理。 + +## 2. 设计论点 + +工作本身有摩擦。界面应该减少阻力。 + +OpenCLI Admin 借鉴 Linear 作为软件系统的工作模型,而不是只借鉴视觉风格: + +- capture:把失败、空结果、无 ACK、疑似站点变化转成可见工作 +- triage:把需要处理的 run 和被动历史分开 +- ownership:清楚显示 Data Source、node、run、责任路径 +- state transition:让 run 的处理状态可推进、可关闭 +- feedback loop:每个动作都立刻返回可检查结果 + +产品不能变成“钟表铺”:一个永远开着的 widget 墙,看起来技术完整,但不告诉操作者下一步该做什么。 + +## 3. 产品表面 + +### 3.1 Run Inbox + +Run Inbox 是主工作队列,用来替代被动的 Recent Runs 表格。 + +Run Inbox 状态是人的处理状态,不是后端 task 执行状态: + +- `running`:run 正在发生,需要可观察 +- `needs_attention`:失败、空结果、超时、无 ACK、阻塞、疑似站点变化 +- `ready_to_review`:有新 records,需要人工判断质量 +- `resolved`:已确认完成或已处理 +- `ignored`:明确忽略,让它停止打扰操作者 + +后端 `pending`、`running`、`completed`、`failed`、`cancelled` 仍然是执行事实。Run Inbox 状态描述的是人的处理工作。 + +### 3.2 Data Sources + +Data Sources 是资源目录,负责配置、健康、归属和 Collection Plans 入口。 + +Data Source 表面回答: + +- 这是什么源? +- 它是否健康? +- 它什么时候运行? +- 最近哪里出过问题? +- 现在应该看哪个 run? + +它不应该永久展示所有 pipeline 细节。 + +### 3.3 Live Collection View + +Live Collection View 绑定到一个 run。 + +当 run 处于 active、needs attention 或 ready to review 时,操作者可以打开它。 + +它只显示当前 run 类型需要的面板: + +- pipeline event stream +- browser / CDP / agent render view +- records preview +- raw output +- screenshots 或 artifacts +- notification ACK state +- error diagnosis +- retry 或 node actions + +默认形态是按需打开的右侧抽屉或可拆出的工作区。全屏只用于 browser render 或 artifact inspection 确实需要空间的场景。 + +### 3.4 Adaptive Run Surface + +Adaptive Run Surface 是 Live Collection View 背后的布局行为。 + +它根据 run 类型和状态选择面板,而不是默认展示所有面板。 + +`react-grid-layout` 用于操作者需要同时比较多个 live artifacts 的场景。它不用于主页面 shell。 + +示例: + +- opencli / CDP run:event stream + browser render + raw output + records +- RSS / API run:event stream + raw response + records +- notification issue:event stream + payload + ACK state +- failed run:event stream + error diagnosis + retry action + +### 3.5 Diagnostic Canvas + +Diagnostic Canvas 是次级表面,用于理解关系、排障链路和未来 workflow authoring。 + +FlowGram 作为 canvas / workflow authoring adapter。我们不自研 canvas infrastructure。 + +Diagnostic Canvas 回答: + +- Data Source、Collection Plan、Run、Record、Notification、Browser Instance、Edge Node 如何关联? +- 当前 run 阻塞在哪里? +- 如果编排或修改 workflow,它会做什么? + +它不作为默认操作入口。 + +## 4. UI 基础 + +### 4.1 Radix + +Radix 提供交互 primitives:Dialog、Popover、Dropdown、Select、Tabs、Tooltip、focus management。 + +行为和 accessibility 重要的地方优先使用 Radix,不本地重造这些交互。 + +### 4.2 FlowGram + +FlowGram 提供 workflow / canvas infrastructure,用于 Diagnostic Canvas 和未来 Workflow Authoring。 + +FlowGram 是 Collection Operations Console 背后的 adapter。Collection Operations 的 domain language 不能依赖 FlowGram 概念。 + +### 4.3 react-grid-layout + +react-grid-layout 提供可调整、可重排的 Adaptive Run Surface 面板。 + +它用于 run 视图,不用于主页面结构。 + +### 4.4 OpenBB Design System + +OpenBB Design System 是密集专业 workbench 的参考和候选依赖。 + +在确认 license 兼容前,不复制或发布 OpenBB 代码。未完成 license check 前,它只作为 design research。 + +### 4.5 现有 desktop / yUI 气质 + +现有 desktop / yUI 工具感是资产: + +- 直接控制 +- 状态可见 +- 密集但可读的面板 +- 不做装饰性叙事 +- 操作者信心优先于营销感 + +现代化不能把这部分抹掉。 + +## 5. 动效 + +动效用于解释状态变化,不用于装饰。 + +使用命名 cubic-bezier tokens: + +- drawer open / close +- panel attach / detach +- new event highlight +- run state transition +- error reveal + +避免: + +- 无限装饰循环 +- 发光式 busywork +- 没有目的的机械 linear motion +- 延迟动作反馈的动画 + +动效要让系统更响应、更可读。 + +## 6. module 形状 + +### 6.1 Collection Operations module + +Collection Operations module 拥有操作采集工作的 domain interface。 + +它隐藏: + +- 后端 task 执行状态 +- query fan-out +- run classification rules +- action availability rules +- inbox grouping +- live panel selection + +它暴露: + +- Run Inbox groups +- selected Data Source summary +- selected Run summary +- available actions +- Live Collection View panel plan + +### 6.2 UI adapters + +UI 表面都是 adapters: + +- Run Inbox adapter +- Data Source directory adapter +- Live Collection View adapter +- Diagnostic Canvas adapter +- Workflow Authoring adapter + +这些 adapters 可以使用 Radix、FlowGram、react-grid-layout 和现有本地组件,但不能拥有 domain rules。 + +## 7. 第一轮实施切片 + +按这个顺序实施: + +1. 引入并固定基础轮子:Radix primitives、FlowGram、react-grid-layout、OpenBB UI license gate。 +2. 新增前端 Run Inbox model。 +3. 在不改后端 schema 的前提下,把现有 tasks / runs 分类到 Run Inbox 状态。 +4. 用 Run Inbox groups 替代 Sources / Collection Operations 里的被动 Recent Runs。 +5. 为 selected run 添加 Live Collection View drawer。 +6. 添加 SSE-backed event stream 和 records preview 面板。 +7. 仅当 run metadata 支持时,添加 browser / CDP render 面板。 +8. 添加 Diagnostic Canvas 入口,并使用 FlowGram adapter。 + +这个顺序保持行为可逆,同时建立正确的 seam。 + +第一版 Run Inbox 状态采用 client-side derived。`running`、`needs_attention`、`ready_to_review` 从现有 `tasks`、`task_runs`、`records`、`notification logs` 推导;`resolved` 和 `ignored` 第一版只保存在本地 UI 状态。等产品形态验证后,再决定是否持久化到后端 schema。 + +部署链路第一版要能看到端到端效果,而不是只做本地静态 demo。Live Collection View 的实时事件传输采用 SSE first, WebSocket later:后端基于现有 `TaskRunEvent` 提供 selected run 的事件流,前端在 run active 时保持 SSE 连接,run 结束后关闭或切换为静态历史。Docker 和 native dev 两条路径都必须可运行,代理、CORS、重连和端口配置纳入第一轮验收。 + +基础轮子不能后补:Radix 已经部分存在,第一轮继续用它承载 drawer/dialog/tabs/select 等 interaction seam;FlowGram 必须以 adapter 形式进入 Diagnostic Canvas 入口;react-grid-layout 必须以 adapter 形式进入 Adaptive Run Surface;OpenBB UI 进入依赖评估 gate,license 兼容前不复制或发布其代码,但设计 token、密度、workbench pattern 的对照要在第一轮完成。 + +## 8. 验收标准 + +- 用户打开 Collection Operations 后能立刻看到哪些 runs 需要处理。 +- 正在运行的 collection 可以在不离开当前操作上下文的情况下观察。 +- 失败或空结果 run 可以在一个地方检查和重试。 +- Data Source 配置不会被埋进 canvas。 +- Diagnostic Canvas 可用,但是次级入口。 +- Collection Operations domain rules 可以不渲染完整页面就测试。 +- FlowGram 和 react-grid-layout 是 adapters,不是 domain dependencies。 + +## 9. 未决问题 + +- 今天能保证哪些 run artifacts:logs、records、raw output、screenshots、browser URL、notification ACK? +- ownership 第一轮指用户归属、node 归属,还是 Data Source 归属? +- OpenBB UI license 验证后,哪些部分可以直接依赖,哪些只能参考? diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md new file mode 100644 index 00000000..fc61a719 --- /dev/null +++ b/docs/GLOSSARY.md @@ -0,0 +1,20 @@ +# Glossary + +Shared vocabulary for OpenCLI Admin. Add terms as the domain model sharpens. + +## Skill subsystem + +- **Skill** — a reusable browser capability identified by `(domain, capability)`, stored in the `skills` table. Body is a `SKILL.md` card plus the structured 9-element spec. +- **SKILL.md** — the human/agent-readable skill card. Carries the 9 elements as prose + front matter. +- **9 elements** — the spec a skill is distilled into: general pattern (scope), preconditions, procedure, milestones, terminal conditions, false terminal states, recovery policies, anti-drift boundaries, red lines. +- **journey_trace_v1** — the trace shape both loop legs share. Produced by the human **record** leg and by every **execute** run (assembled from step events + outcome); consumed by the distiller. +- **Distiller** — `backend/skills/distill.py`. Turns one `journey_trace_v1` trace (+ optionally the current SKILL.md) into a skill spec via a provider LLM. The single converter for both record and correct legs. +- **Execute loop** — the `skill` channel's perceive→propose→confirm→act cycle: snapshot the page, cheap model emits one action, gate it, run it, emit a step event, check milestones/terminal. +- **Perception snapshot** — the per-step page view given to the model: an injected-JS list of visible interactive elements `[{ref, role, name, value}]`, token-bounded. +- **ref** — a per-snapshot `data-skill-ref` id the model uses to address an element in an action. +- **proposal→confirm guardrail** — the dock's hard rule that write actions are not executed until confirmed. The execute loop reuses it for high-risk actions. +- **Risk-tiered confirm** — reads/navigation/scroll/extract auto-run; red-line / high-risk actions (submit, pay, post, delete) require confirm. +- **auto_confirm** — a per-source flag letting a trusted skill run high-risk actions unattended. Default off. +- **awaiting_confirm** — paused run status: a headless run hit a confirm-required action and stopped (resume is v2). +- **Record leg / Correct leg** — the two trace sources: a human demonstrating a task once (record), and a failing execute run fed back for re-distillation (correct). +- **Evidence** — the `skills.evidence` log of closed-loop events (distilled / executed / corrected with outcomes) that drives self-evaluation. diff --git a/docs/PROJECT_MANAGEMENT.md b/docs/PROJECT_MANAGEMENT.md index 9f713d7e..45d92f66 100644 --- a/docs/PROJECT_MANAGEMENT.md +++ b/docs/PROJECT_MANAGEMENT.md @@ -1,7 +1,56 @@ # opencli-admin 项目管理 > 版本: v0.1.0 -> 日期: 2026-06-19 +> 日期: 2026-06-24 + +--- + +## 0. 当前进度快照(2026-06-24) + +### 0.1 当前状态 + +| 项 | 状态 | +|---|---| +| 当前工作目录 | `D:\projects\opencli-admin` | +| 当前分支 | `codex/sources-canvas-topology-view-mode` | +| 最近提交 | `f9e90b1 chore: add ...` | +| 本地改动 | 前端工作区存在未提交改动,集中在拓扑、Sources、Settings、i18n、节点动作和布局 | +| 验证结果 | `npm test`、`npm run typecheck`、`npm run lint`、`npm run build:frontend` 均通过 | +| 构建提示 | Vite 报 chunk 大于 500 kB 的体积警告,非阻塞 | +| code-intel | 2026-06-24 normal 模式已生成报告;hospital=red,score=66 | + +code-intel 的 red 不是因为当前测试失败,而是诊断链路还缺两个治理信号: + +- Understand graph 缺失,需要手动运行 `/understand D:\projects\opencli-admin --language zh`。 +- Sentrux baseline/rules 缺失,架构 gate 只能停在 triage,不能作为合并前治理结论。 + +### 0.2 已完成或基本落地 + +- `frontend/` 是唯一生产前端主线,React + Vite + Tailwind 是当前可运行产品面。 +- GitHub Actions 已拆成 frontend、extension、backend 三条真实流水线,不再依赖 Nx 聚合证明主线健康。 +- 默认 Compose 已改为从 `./frontend` 构建前端镜像,避免继续显示上游旧前端镜像。 +- `DESIGN.md` 已作为设计源,明确默认深色、操作台密度、拓扑/节点动作/Settings 的方向。 +- Topology Workbench 已降级为 `frontend/src/labs/topology/` 实验视图,需 `VITE_ENABLE_TOPOLOGY_LAB=true` 才开放。 +- Sources 页面暂不默认切换为画布工作台,后续先稳定采集源列表/详情操作闭环。 +- Settings 页面已新增,承载语言、主题、密度偏好和对话触发节点动作的实验入口。 +- zh/en i18n 字典已大幅扩展,并新增本地化审计文件用于后续清理硬编码中文。 +- `nodeActions` 与 `nodeRunService` 已新增,包含对话指令解析与节点动作执行的单元测试。 + +### 0.3 进行中 + +- 前端改动尚未提交,需要先做一次 diff review,必要时拆成 P0 基线、labs topology、后续 UI 三个提交。 +- 项目路线已收敛到当前 Vite 产品面;Next/Turborepo 迁移线降级为历史设想和 `experiments/next-web/` 实验。 +- i18n 审计仍显示多个页面存在硬编码中文,尤其是运行故事板、数据源、Settings、任务页等,需要分批清理。 +- Sentrux gate 缺 baseline/rules,若后续要用架构门禁,需要先创建基线和规则。 +- Understand graph 需要补跑,补齐后再看 code-intel hospital 是否能从 red 降级。 + +### 0.4 下一步建议 + +1. 完成 P0 diff review,确认 Docker、CI、文档都只指向 `frontend/` 主线。 +2. 对 Dashboard、Sources、Settings 做默认 smoke check;拓扑只在 `VITE_ENABLE_TOPOLOGY_LAB=true` 下检查 `/labs/topology`。 +3. 提交当前功能分支,提交信息建议围绕 `chore(frontend): establish Vite as the sole frontend mainline`。 +4. 后续再拆 SourcesPage 和设计系统,不在 P0 中继续扩大画布范围。 +5. 补齐 Sentrux baseline/rules 和 Understand graph,让 code-intel 报告可以作为后续进度门禁。 --- @@ -29,18 +78,18 @@ | 任务 | 标签 | 优先级 | 状态 | |------|------|--------|------| -| 创建 Monorepo 结构 (Turborepo) | `infrastructure` | 🔴 高 | ✅ Done | -| 配置 Next.js App Router | `frontend` | 🔴 高 | ✅ Done | -| 配置 Turborepo CI/CD | `infrastructure` | 🔴 高 | 🔄 In Progress | +| 确认 Vite 前端主线 | `frontend` | 🔴 高 | ✅ Done | +| 降级 Next.js 壳为实验目录 | `frontend` | 🟡 中 | ✅ Done | +| 拆分 frontend / extension / backend CI | `infrastructure` | 🔴 高 | ✅ Done | | 添加 Docker 支持 | `infrastructure` | 🔴 高 | ✅ Done | | 配置 ESLint + Prettier | `infrastructure` | 🟡 中 | ⬜ To Do | -| 配置 GitHub Actions | `infrastructure` | 🔴 高 | ⬜ To Do | +| 配置 GitHub Actions | `infrastructure` | 🔴 高 | ✅ Done | ### 2.2 Phase 2: 前端现代化 🎨 | 任务 | 标签 | 优先级 | 状态 | |------|------|--------|------| -| 迁移现有组件到 Next.js | `frontend` | 🔴 高 | ⬜ To Do | +| 拆分 Vite 页面和领域组件 | `frontend` | 🔴 高 | ⬜ To Do | | 添加 shadcn/ui 组件 | `frontend` | 🟡 中 | ⬜ To Do | | 实现 DataTable 虚拟滚动 | `frontend` `performance` | 🟡 中 | ⬜ To Do | | 添加 ErrorBoundary | `frontend` | 🟡 中 | ⬜ To Do | @@ -165,9 +214,9 @@ assignees: '' ### v0.1.0 - MVP (1周) - [x] Monorepo 结构 -- [x] Next.js 骨架 +- [x] Vite 前端主线 - [x] Docker 支持 -- [ ] Turborepo CI/CD +- [x] 分离式 GitHub Actions CI - [ ] GitHub Actions - [ ] README diff --git a/docs/adr/0001-use-flowgram-for-canvas-infrastructure.md b/docs/adr/0001-use-flowgram-for-canvas-infrastructure.md new file mode 100644 index 00000000..0ca29137 --- /dev/null +++ b/docs/adr/0001-use-flowgram-for-canvas-infrastructure.md @@ -0,0 +1,5 @@ +# Use FlowGram for canvas infrastructure + +OpenCLI Admin will not build its own workflow canvas, node form, or workflow authoring infrastructure. We will use FlowGram as the adapter behind Diagnostic Canvas and Workflow Authoring surfaces, while Collection Operations remains the primary domain module and operator UX. This keeps canvas implementation replaceable and lets the product invest in human task flow instead of rebuilding low-level canvas/form/variable-scope mechanics. + +Implementation note: pin FlowGram packages to `1.0.11` until the npm registry exposes a consistent `1.0.12` dependency set. On 2026-06-25, `@flowgram.ai/editor@1.0.12` and layout packages require `@flowgram.ai/utils@1.0.12`, but that package version is not available from the registry. diff --git a/docs/adr/0002-use-accessible-operator-ui-foundations.md b/docs/adr/0002-use-accessible-operator-ui-foundations.md new file mode 100644 index 00000000..984d824c --- /dev/null +++ b/docs/adr/0002-use-accessible-operator-ui-foundations.md @@ -0,0 +1,3 @@ +# Use accessible operator UI foundations + +OpenCLI Admin will build Collection Operations and Live Collection View surfaces from proven UI foundations instead of inventing a component library. Radix provides accessible primitives; FlowGram provides canvas/workflow authoring infrastructure; react-grid-layout provides adaptive, resizable run surfaces. OpenBB's design system is a reference and candidate dependency for dense financial-workbench patterns, but a public GitHub repository is not license clearance: do not copy or ship OpenBB design-system code until its package/repository license is explicitly verified. The product should borrow from Linear as software, not just as visual style: work is frictional, so the system should make capture, triage, ownership, state transition, and feedback loops fast and explicit. Motion should use explicit cubic-bezier tokens, stay purposeful, preserve the existing desktop/yUI operational feel, and avoid always-on "clock shop" dashboards. diff --git a/docs/adr/0003-skill-execute-loop-architecture.md b/docs/adr/0003-skill-execute-loop-architecture.md new file mode 100644 index 00000000..93422bd6 --- /dev/null +++ b/docs/adr/0003-skill-execute-loop-architecture.md @@ -0,0 +1,15 @@ +# Skill execute loop architecture + +The skill subsystem closes a record → distill → store → execute → correct loop on top of OpenCLI Admin's existing perception/execution substrate. This ADR fixes how the **execute** leg works: a `skill` channel that reads a distilled `SKILL.md` and lets a cheap model drive a real Chrome page step by step. The guiding constraint is maximum reuse of what already exists (browser pool, task/run/event/record models, the agent dock's proposal→confirm guardrail, model providers) and minimum new surface. + +Decisions: + +- **Placement — center-side first.** The loop runs in the backend process: acquire a CDP endpoint from `browser_pool`, then drive it with Playwright (`connect_over_cdp`). The repo currently has no in-process page-driving primitives — `agent_server` only shells `opencli collect`, and there is no CDP client dependency — so Playwright is a new backend dependency. Center placement keeps the chatty perceive→act loop tight against the model provider and the records/events DB. This reaches local and LAN CDP endpoints only; driving NAT edge nodes through `agent_server` is deferred, mirroring how `collect` added agent mode after local mode. +- **Perception — injected-JS interactive snapshot with refs.** Each step injects JS that tags visible interactive elements (`a/button/input/select/[role]`) with a sequential `data-skill-ref` and returns a compact `[{ref, role, name, value}]` list; actions address elements by `ref`. The executor model is a small text model (e.g. `qwen3:4b`, ~32k context, not vision), so raw DOM and screenshots are rejected on token and capability grounds. Snapshots are token-bounded/paginated for large pages. +- **Action space — small fixed verb set, ref-addressed.** `navigate{url}`, `click{ref}`, `type{ref,text,submit?}`, `select{ref,value}`, `scroll{dir}`, `extract{data}` (emits a record), `done{status,note}`. No `evaluate(js)` escape hatch — arbitrary JS is an uncontrollable red line and stays out of the model's hands. The model emits one tool call per step, reusing the dock's tool-calling harness (OpenAI `tool_calls` plus the Qwen XML-tool variant). +- **Guardrail — risk-tiered confirm.** Reads, navigation, scroll, and extract auto-run. Actions matching the skill's `red_lines` or a configured high-risk pattern (submit, pay, post, delete) require confirm, reusing the dock's proposal→confirm contract ("写前确认是硬底线"). Default does not bypass; a source may set `auto_confirm=true` to run a trusted skill unattended. +- **Run integration — stay inside the task/run/pipeline spine, no contract change.** A `skill` `DataSource` flows through the existing `run_pipeline` → `channel.collect()` path, reusing scheduling, triggers, tasks, runs, dedup, and the run-events UI. The pipeline passes `run_id` into the channel via `parameters` (it already special-cases channels); the loop emits per-step `TaskRunEvent`s through the module-level `events.emit(run_id, ...)`, so `AbstractChannel.collect()` keeps its signature. `extract` results return as `ChannelResult.items` and go through normal normalize/store/dedup/AI/notify. A new paused run status (`awaiting_confirm`) is added. +- **Loop control from the 9 elements.** Each step's system prompt carries the SKILL.md `procedure`, `milestones`, `terminal_conditions`, `false_terminal_states`, and `red_lines` alongside the current snapshot. `false_terminal_states` are listed explicitly to stop premature `done`; `terminal_conditions` validate a claimed `done`; the loop ends on `done{}` or a max-step cap. +- **Self-eval and correction — unified re-distill from execution traces.** Every execute run emits a `journey_trace_v1`-shaped trace assembled from its step events and outcome, so the human **record** leg and the **correct** leg feed the *same* distiller. Self-evaluation compares run outcome against the skill's `terminal_conditions`/`milestones`; on repeated failure or an explicit human trigger, the failing trace(s) plus the current SKILL.md are re-distilled into version *n+1* (version bump, `evidence` appended). Correction is re-distillation, never hand-patching. + +v1 scope: interactive-first. v1 ships dock-driven interactive execution with synchronous confirm (reusing the existing chat proposal→confirm); headless/scheduled runs abort with `awaiting_confirm` + event when a confirm-required action is reached, and skills with no high-risk actions (or `auto_confirm=true`) run fully headless. Cross-process pause/resume and auto-triggered re-distill (after N consecutive failures) are deferred to v2; v1 re-distill is human-triggered from the dock. The human **record** leg ("录这站") that produces the initial trace is a separate TODO; the execute loop only fixes the trace *shape* (`journey_trace_v1`) both legs must share. diff --git a/docs/skills-execute-loop-PRD.md b/docs/skills-execute-loop-PRD.md new file mode 100644 index 00000000..e80a2659 --- /dev/null +++ b/docs/skills-execute-loop-PRD.md @@ -0,0 +1,177 @@ +# PRD — Skill execute loop (v1) + +Status: draft · Owner: skills subsystem · Source of truth for architecture: `docs/adr/0003-skill-execute-loop-architecture.md` · Glossary: `docs/GLOSSARY.md` + +> The 8 architecture decisions in ADR-0003 are **fixed**. This PRD turns them into a buildable v1: it states the goal, the flows, the concrete integration points in *this* codebase, the data-model delta, the risks, and a hard scope cut-line. The issue breakdown at the end decomposes v1 into independently grabbable units. + +--- + +## 1. Goal & non-goals + +### Goal +Close the **execute** leg of the skill subsystem: a `skill` channel that loads a distilled `SKILL.md` and lets a *cheap* text model (e.g. `qwen3:4b`) drive a real Chrome page step by step — perceive → propose → confirm → act — staying entirely inside the existing task / run / pipeline / events spine, reusing the agent dock's proposal→confirm guardrail, and emitting a `journey_trace_v1`-shaped trace so a failing run can be fed back to the **same** distiller (`backend/skills/distill.py`) for correction (re-distill, never hand-patch). + +A skill `DataSource` must be runnable two ways with the *same* loop: +1. **Dock interactive** — a human watches in the agent dock, confirms high-risk actions synchronously. +2. **Headless / scheduled** — runs unattended; aborts cleanly with run status `awaiting_confirm` the moment a confirm-required action is reached (unless the skill is risk-free or the source is marked `auto_confirm`). + +### Non-goals (v1) +- **The human *record* leg ("录这站")** that produces the *first* `journey_trace_v1` from a human demonstration. v1 only fixes the trace **shape** both legs must share; producing it from a recording is a separate TODO. +- **Cross-process pause / resume.** A headless run that hits a confirm-required action stops at `awaiting_confirm`; resuming it later (re-attaching the page, replaying state) is **v2**. +- **Auto-triggered re-distill after N consecutive failures.** v1 re-distill is **human-triggered** from the dock. The self-eval signal (outcome vs `terminal_conditions`/`milestones`) is computed and logged to `skills.evidence` in v1, but the *automatic* "after N fails, re-distill" policy is v2. +- **NAT / edge-node execution.** v1 reaches **local + LAN** CDP endpoints only (via `browser_pool`). Driving NAT edge nodes through `agent_server` is deferred, mirroring how `opencli collect` added agent mode after local mode. +- **Vision models, raw-DOM dumps, screenshots, `evaluate(js)`.** Rejected by ADR on token / capability / safety grounds. + +--- + +## 2. Background — what STEP 1 already landed + +The record→distill→store skeleton and the channel shell already exist: + +| Area | File / symbol | State | +|---|---|---| +| Distiller (the single converter for both legs) | `backend/skills/distill.py` — `distill_trace(trace, provider)`, `provider_from_model(mp)`, `to_skill_fields(spec)`, `call_llm`, `extract_json`, `ELEMENT_KEYS` | Done. Pure (no DB/FS writes); takes a `journey_trace_v1` trace + provider config → 9-element spec. | +| Skill model | `backend/models/skill.py` — `Skill` | Done. `(domain, capability)` unique; `skill_md`, `elements` JSON, `evidence` JSON, `source_trace`, `distill_model`, `status`/`version`/`enabled`. | +| Migration | `backend/migrations/versions/m3h4i5j6k7l8_add_skills.py` | Done. Current Alembic head. | +| Skill channel shell | `backend/channels/skill_channel.py` — `SkillChannel(channel_type="skill")` | **Skeleton.** Validates config, acquires a browser from `browser_pool`, resolves provider + `auto_confirm`, returns a single `proposed_step` stub and stops at the confirm gate. **No perceive/act loop, no events, no extract.** | +| Registration | `backend/channels/registry.py` — `skill_channel` imported in `_load_all_channels()` | Done. `get_channel("skill")` resolves. | + +So the seam is open: `SkillChannel.collect()` already gets `config` + `parameters`, already binds a CDP endpoint string. What's missing is everything between "I have a CDP endpoint" and "here are the extracted records + a trace". + +### Substrate this builds on (already in the repo) +- **`backend/browser_pool.py`** — `get_pool().acquire(endpoint=None)` async-context yields a **CDP endpoint URL string**; `pool.get_mode(ep)` returns `"bridge"` or `"cdp"`. **No Playwright / CDP client dependency exists** — confirmed: zero `playwright`/`connect_over_cdp` references in `opencli_channel.py`, `agent_server.py`, or `pyproject.toml`. `agent_server` only shells `opencli collect`. **Playwright is a new backend dependency** (ADR D1). +- **Pipeline spine** — `backend/pipeline/runner.py::run_collection_pipeline` creates the `TaskRun`, resolves provider/agent config, calls `run_pipeline`. `backend/pipeline/pipeline.py::run_pipeline` runs collect→normalize→store→ai→notify and **already special-cases `channel_type=="opencli"`** to inject `chrome_endpoint` into `parameters` and to build a richer collect event. `backend/pipeline/collector.py::collect` dispatches `get_channel(source.channel_type).collect(source.channel_config, parameters)`. +- **Events** — `backend/pipeline/events.py::emit(run_id, step, message, level, detail, elapsed_ms)` writes one `TaskRunEvent` (best-effort, never raises). `TaskRunEvent` model in `backend/models/task.py`. +- **Guardrail** — `backend/api/v1/chat.py` is the proposal→confirm reference: `TOOLS` (OpenAI function schema), `WRITE_TOOLS` set, `_build_proposal`, `/chat/confirm`, `_is_xml_tool_model` + `_parse_tool_use` (Qwen XML `` variant). Frontend dock: `frontend/src/labs/topology/AgentDock.tsx` already renders `ChatReply{type:"proposal"}` as a diff card and posts to `/chat/confirm`. + +--- + +## 3. v1 user / operator flows + +### Flow A — Dock interactive run (the v1 happy path) +1. Operator opens the agent dock on `/labs/topology`, selects (or references) a `skill` `DataSource` and triggers a run (reuses the existing `trigger_task` proposal→confirm, or a thin "run skill" entry). +2. Backend creates a `CollectionTask` + `TaskRun` (`runner.run_collection_pipeline`), `run_pipeline` injects `run_id` + a resolved `chrome_endpoint` into `parameters`, and dispatches `SkillChannel.collect()`. +3. The loop, per step: **perceive** (inject JS, get `[{ref,role,name,value}]` snapshot) → build the step system prompt from the SKILL.md 9 elements + snapshot → **cheap model** emits **one** action (verb set) → **risk gate**: + - read / navigate / scroll / extract → **auto-run**, emit a `skill_step` `TaskRunEvent`. + - matches `red_lines` or high-risk pattern (submit/pay/post/delete) → emit an `awaiting_confirm` event carrying a **proposal** (same `Proposal` shape as chat); the dock shows the diff card; operator confirms; loop resumes and runs the action. +4. `extract{data}` actions accumulate as `ChannelResult.items`. Loop ends on `done{status,note}` (validated against `terminal_conditions` / `false_terminal_states`) or a max-step cap. +5. Items flow through the **normal** normalize → store → dedup → AI → notify pipeline. The run also emits a `journey_trace_v1` trace (assembled from the step events + outcome) and a self-eval summary, appended to `skills.evidence`. +6. If the run failed (self-eval says outcome ≠ terminal conditions), the operator can click **"重蒸技能 / re-distill"** in the dock → feeds the failing trace(s) + current `SKILL.md` back into `distill_trace` → `skills.version++`, `evidence` appended, new `SKILL.md`. + +### Flow B — Headless / scheduled run +1. A `CronSchedule` (or manual headless trigger) runs the same `skill` `DataSource` via `run_scheduled_pipeline` → `run_collection_pipeline` → identical loop. There is **no human** at a dock. +2. Auto-run tiers (read/navigate/scroll/extract) proceed unattended; `extract` records accumulate. +3. The moment a confirm-required action is proposed: + - if `source.channel_config.auto_confirm == true` **or** the skill has **no** high-risk action → the action runs; the run completes headless. + - otherwise → the loop **aborts**: emits an `awaiting_confirm` `TaskRunEvent` (with the proposed action), returns a `ChannelResult` that drives the run to status **`awaiting_confirm`**, and stops. (Resume is v2.) +4. Whatever was extracted before the abort still flows through normalize/store. The trace + self-eval are still emitted (outcome = "paused: awaiting_confirm"). + +--- + +## 4. Architecture — the 8 decisions, concrete to this codebase + +> ADR-0003 is the authority. This section pins each decision to files/symbols so an implementer doesn't have to re-derive them. + +**D1 — Placement: center-side, Playwright over CDP.** +The loop runs in the backend process. Acquire a CDP endpoint via `backend/browser_pool.py::get_pool().acquire(endpoint=...)` (already done in the skeleton), then drive it with **Playwright** `playwright.async_api.async_playwright().chromium.connect_over_cdp(cdp_endpoint)`. **Playwright is a NEW backend dependency** (add to `pyproject.toml`; `playwright install chromium` for the driver). Wrap the connection in a small `SkillPage` helper (new module under `backend/skills/`) exposing `goto`, `query interactive`, `click(ref)`, `type(ref,text,submit)`, `select(ref,value)`, `scroll(dir)`, `inner_text/extract`. Local + LAN endpoints only. `connect_over_cdp` attaches to the existing browser context, so a logged-in page (e.g. site cookies already present in that Chrome) is reused — same substrate the opencli channel relies on. + +**D2 — Perception: injected-JS interactive snapshot.** +Each step injects JS (via `page.evaluate`) that walks visible `a, button, input, select, [role]`, assigns a sequential `data-skill-ref="N"` to each, and returns a compact `[{ref, role, name, value}]` list. No raw DOM, no screenshots (cheap model is text-only, ~32k ctx). The snapshot is **token-bounded / paginated** (cap element count; `scroll` to reach more). This lives in the perception module (new, under `backend/skills/`). + +**D3 — Action space: small fixed verb set, ref-addressed.** +Exactly: `navigate{url}`, `click{ref}`, `type{ref,text,submit?}`, `select{ref,value}`, `scroll{dir}`, `extract{data}` (emits a record into `ChannelResult.items`), `done{status,note}`. **No `evaluate(js)`** exposed to the model. One tool call per step. Tool-calling reuses the chat harness pattern from `backend/api/v1/chat.py`: OpenAI `tool_calls` for normal models; the Qwen XML variant (`_is_xml_tool_model`, `_parse_tool_use`, ``) for `qwable`-style models. The verb set is defined as its own `TOOLS`-shaped schema + `WRITE_TOOLS`-style risk set for the skill loop (do **not** overload the chat-console tools). + +**D4 — Guardrail: risk-tiered confirm.** +`reads / navigate / scroll / extract` → **auto-run**. An action whose target/verb matches the skill's `red_lines` **or** a configured high-risk pattern (`submit | pay | post | delete`, applied to the verb + element name/role) → **confirm required**. Reuse the chat `Proposal{tool,args,summary,diff}` shape and the dock's confirm contract. `source.channel_config.auto_confirm == true` bypasses (default **off**). The risk classifier is a small pure function (testable in isolation). + +**D5 — Run integration: stay in the spine, no `collect()` contract change.** +A `skill` `DataSource` flows through `run_pipeline → collector.collect → SkillChannel.collect`. `run_pipeline` must pass `run_id` into the channel via `parameters` (it already injects things into `parameters` for `opencli`; add a `skill` branch that sets `parameters["run_id"] = run_id` and, if a binding exists, `parameters["chrome_endpoint"]`). **`AbstractChannel.collect(config, parameters)` signature is unchanged.** The loop emits per-step events through the module-level `events.emit(run_id, ...)`. `extract` results return as `ChannelResult.items` and go through the **normal** `normalizer → storer` (dedup/AI/notify) path. A new paused run status **`awaiting_confirm`** is added (see §5). Cheap-executor provider config arrives the same way distill's does — from a `ModelProvider` via `provider_from_model`, surfaced into `config["provider"]` / `parameters`. + +**D6 — Loop control from the 9 elements.** +Each step's system prompt carries the SKILL.md `procedure`, `milestones`, `terminal_conditions`, `false_terminal_states`, `red_lines` (from `Skill.elements` / `skill_md`) **plus** the current snapshot. `false_terminal_states` are listed explicitly so the model doesn't `done` prematurely; `terminal_conditions` validate a claimed `done`; the loop ends on `done{}` or a max-step cap (config, e.g. `max_steps`, default ~20). + +**D7 — Self-eval & correction: unified re-distill.** +Every execute run assembles a `journey_trace_v1`-shaped trace from its step events + outcome (so the human record leg and the correct leg feed the **same** `distill_trace`). Self-eval compares outcome against `terminal_conditions`/`milestones` and writes a result into `skills.evidence`. On **human trigger** (v1) — the dock "重蒸技能" button — the failing trace(s) + current `SKILL.md` are passed to `distill_trace` → `skills.version++`, `evidence` appended, `skill_md`/`elements` replaced. **Correction is re-distillation, never a hand-patch.** (Auto-trigger after N fails = v2.) + +**D8 — v1 scope: interactive-first.** +v1 ships dock-driven interactive execution with **synchronous** confirm (reuse chat proposal→confirm). Headless/scheduled runs abort with `awaiting_confirm` + event on a confirm-required action; risk-free or `auto_confirm` skills run fully headless. Cross-process pause/resume + auto re-distill = v2. v1 re-distill = human-triggered from the dock. Record leg = separate TODO; execute loop only fixes the `journey_trace_v1` shape. + +### `journey_trace_v1` shape (the contract both legs share) +The distiller (`distill_trace`) already reads: `trace["summary"]["domain"]`, `trace["label"]`, `trace["trace_id"]`. v1 must emit **at least** these, plus a `steps[]` array (one entry per loop step: action, ref/target, snapshot digest, result, timing) and an `outcome` block (success/failed/paused, milestones hit, terminal check). Define the schema once in a shared module (e.g. `backend/skills/trace.py`) so the future record leg targets the same shape. Keep it forward-compatible (extra keys ignored by the distiller). + +--- + +## 5. Data model changes + +The `Skill` table is **already** present (model + migration) and needs **no schema change** for v1 (`version`, `evidence`, `status`, `enabled` already exist; re-distill mutates rows, not columns). + +### New: `awaiting_confirm` run status +- `TaskRun.status` (in `backend/models/task.py`) is a free-text `String(50)` — no DB enum to alter — so **no Alembic column change is strictly required** to store the value. However, v1 **must**: + 1. Treat `awaiting_confirm` as a recognized terminal-ish status in `runner.run_collection_pipeline` Phase 4 (do **not** force it to `completed`/`failed` when the pipeline reports a paused outcome). + 2. Surface it wherever run statuses are enumerated/filtered (run-list/run-detail API + the dock/run UI legend). +- `PipelineResult` / `ChannelResult` need a way to signal "paused, awaiting confirm" up to the runner so Phase 4 sets `run.status = "awaiting_confirm"` instead of `completed`. Carry it in `ChannelResult.metadata` (e.g. `metadata["awaiting_confirm"] = True` + the proposed action) → `PipelineResult.metadata` → runner. +- **Migration:** add a **data/comment migration** `n4i5j6k7l8m9_add_awaiting_confirm_run_status` with `down_revision = 'm3h4i5j6k7l8'` (current head). Even though `status` is free-text, ship the migration as the **anchor** for this feature (and to update any check-constraint/comment the project later adds, and to keep the chain explicit). Its `upgrade()` may be a no-op/comment if no column changes — but it documents the new status and keeps Alembic head ownership clear for the feature branch. + +### `auto_confirm` source flag +Stored in `DataSource.channel_config` JSON (`channel_config["auto_confirm"]: bool`, default `false`) — **no schema change**. `SkillChannel.validate_config` should accept it; the risk gate reads it from `config`. + +--- + +## 6. Integration points (exact functions) + +| Concern | Exact site | Change | +|---|---|---| +| Pass `run_id` + endpoint into the channel | `backend/pipeline/pipeline.py::run_pipeline` (pre-step + collect block, currently special-casing `channel_type=="opencli"`) | Add a `channel_type=="skill"` branch: set `params["run_id"]=run_id`; resolve `chrome_endpoint` from a browser binding if present (reuse `browser_service.get_binding_by_site`-style logic or a skill-specific binding). Build a `skill`-flavored collect event detail. | +| Dispatch (unchanged) | `backend/pipeline/collector.py::collect` | No change — already `get_channel("skill").collect(config, params)`. | +| Per-step events | `backend/pipeline/events.py::emit(run_id, step, ...)` | Reuse as-is. New `step` values: `skill_perceive`, `skill_step`, `awaiting_confirm`, `skill_extract`, `skill_done`, `self_eval`. (`TaskRunEvent.step` is free-text `String(50)`.) | +| Extract → records | `backend/channels/base.py::ChannelResult.ok(items, **metadata)` | `extract` actions append to `items`; loop returns `ChannelResult.ok(items, channel="skill", executed=True, awaiting_confirm=, trace=)`. Items then hit `normalizer.normalize_items` → `storer.store_records` unchanged. | +| Paused status up the stack | `backend/pipeline/pipeline.py::run_pipeline` return + `backend/pipeline/runner.py::run_collection_pipeline` Phase 4 | Propagate `metadata["awaiting_confirm"]`; in Phase 4 set `run.status="awaiting_confirm"` (not `completed`) when set. | +| Cheap executor provider | `backend/skills/distill.py::provider_from_model` (pattern) + `runner` provider resolution (lines ~94–141) | Resolve the executor model the same way; surface into `config["provider"]`. May differ from the distill model. | +| Tool-calling harness | `backend/api/v1/chat.py` — `_is_xml_tool_model`, `_parse_tool_use`, OpenAI `tool_calls` loop | Reuse the *pattern* (extract a tiny shared helper if convenient); define a **separate** skill verb schema + risk set. Do not reuse the chat-console `TOOLS`. | +| Confirm contract / dock | `backend/api/v1/chat.py::Proposal` + `/chat/confirm`; `frontend/src/labs/topology/AgentDock.tsx` | Interactive confirm reuses the `Proposal{tool,args,summary,diff}` shape and the dock's diff-card → confirm flow. The "重蒸技能" trigger is a new dock action that calls a new re-distill endpoint. | +| Re-distill | `backend/skills/distill.py::distill_trace` + `to_skill_fields` | New service/endpoint loads the `Skill` + failing trace(s), calls `distill_trace(trace, provider)`, bumps `version`, appends `evidence`, writes `skill_md`/`elements`. | +| Browser driving | `backend/browser_pool.py::get_pool().acquire` (done) + **new** Playwright wrapper | `connect_over_cdp(cdp_endpoint)`; new dep. | + +--- + +## 7. Risks & open items + +- **Record leg is a separate TODO.** v1 fixes only the `journey_trace_v1` *shape*. Until the record leg exists, the **only** trace source is execute runs (correct leg). Initial `SKILL.md` cards must be seeded another way (inline `skill_md`, or a hand-written trace) — acceptable for v1 since the channel already accepts inline `config["skill_md"]`. +- **Windows Playwright install.** Dev/CI is Windows (`win32`). Playwright needs `playwright install chromium` and the matching driver; document this in `TESTING.md` and gate the e2e test behind the existing `live` pytest marker (`-m "not live"` deselects it) so the default `--cov-fail-under=80` suite doesn't require a browser. The loop *connects over CDP* to an already-running Chrome (from `browser_pool`), so the bundled Chromium is only needed for the driver, not necessarily a second browser. +- **Cross-process resume = v2.** A headless run that hits a confirm-required action **cannot** be resumed in v1; it ends at `awaiting_confirm`. Operators must re-run interactively (or set `auto_confirm`) to get past it. Make this explicit in the dock/run UI. +- **Auto re-distill = v2.** v1 computes & logs self-eval to `evidence` but only **human**-triggers re-distill. Don't wire an automatic "N fails → re-distill" loop. +- **Risk classifier false-negatives are the danger.** A high-risk action mis-classified as auto-run is a silent write. Keep the classifier conservative (default to confirm on ambiguity), unit-test it hard, and keep `red_lines` authoritative over the generic pattern. +- **Token blow-up on huge pages.** Snapshot must be bounded/paginated; an unbounded interactive list will exceed the cheap model's ~32k context. Cap element count and rely on `scroll`. +- **`connect_over_cdp` reuses live state.** Driving a shared logged-in Chrome means the loop can see/affect real sessions — another reason the write-gate is a hard line, and why v1 is local/LAN only. +- **`step` / `status` are free-text.** Convenient (no enum migrations) but means typos won't be caught by the DB; centralize the string constants. + +--- + +## 8. v1 scope cut-line + +**In v1:** +- Playwright dep + `connect_over_cdp` page wrapper + injected-JS perception snapshot. +- Fixed verb set executor (ref resolution → Playwright ops), one action/step. +- Cheap-model step loop reusing the chat tool-calling harness pattern (OpenAI + Qwen XML), driven by the 9 elements + snapshot, ending on `done`/cap. +- Risk-tiered confirm gate + `auto_confirm` + `awaiting_confirm` run status + anchor migration. +- Run integration: `run_id` via `parameters`, per-step `events.emit`, `extract → ChannelResult.items` → normal store, paused status propagation, `SkillChannel.collect` fully wired. +- `journey_trace_v1` emission from a run + human-triggered re-distill (service/endpoint + dock "重蒸技能" button) + self-eval logged to `evidence`. +- e2e against a real local Chrome (behind `live` marker). + +**Out (v2+):** record leg ("录这站"); cross-process pause/resume; auto-triggered re-distill after N fails; NAT/edge-node execution via `agent_server`; vision/raw-DOM/screenshot perception; `evaluate(js)`. + +--- + +## 9. v1 issue breakdown (build order) + +Each issue is implementable in a fresh session from this PRD + ADR-0003 alone. IDs are build order; `depends_on` is explicit. + +1. **01 — Playwright dep + CDP page wrapper + perception snapshot** (`backend/skills/page.py`, `backend/skills/perception.py`, `pyproject.toml`, `TESTING.md`). No deps. +2. **02 — Action executor: verb set → Playwright ops, ref resolution** (`backend/skills/actions.py`). Depends 01. +3. **03 — Cheap-model step loop + 9-element prompt + tool-calling harness** (`backend/skills/loop.py`, `backend/skills/prompt.py`). Depends 01, 02. +4. **04 — Risk-tiered confirm gate + auto_confirm + awaiting_confirm status + migration** (`backend/skills/risk.py`, migration, `backend/models/task.py` usage, `backend/channels/base.py` metadata). Depends 03 (gate sits in the loop; can be developed against the loop's action stream). +5. **05 — Run integration: wire SkillChannel.collect into the spine** (`backend/channels/skill_channel.py`, `backend/pipeline/pipeline.py`, `backend/pipeline/runner.py`). Depends 03, 04. +6. **06 — journey_trace_v1 emission + re-distill correction path + dock "重蒸技能"** (`backend/skills/trace.py`, `backend/skills/correction.py`, new API endpoint, `frontend/src/labs/topology/AgentDock.tsx`). Depends 05. +7. **07 — e2e against a real local Chrome (live marker)** (`tests/skills/`). Depends 05 (06 optional for the trace assertion). + +See the structured output for full per-issue files / acceptance criteria / dependencies. diff --git a/docs/skills-issues/01-playwright-dependency-cdp-page-wrapper-injected-js.md b/docs/skills-issues/01-playwright-dependency-cdp-page-wrapper-injected-js.md new file mode 100644 index 00000000..ec1bba71 --- /dev/null +++ b/docs/skills-issues/01-playwright-dependency-cdp-page-wrapper-injected-js.md @@ -0,0 +1,123 @@ +# 01 Playwright dependency + CDP page wrapper + injected-JS perception snapshot + +> Self-contained issue. Source of truth for the design: `docs/adr/0003-skill-execute-loop-architecture.md` (decisions **D1**, **D2**) and `docs/skills-execute-loop-PRD.md` (§4 D1/D2, §8 cut-line, §9 issue 01). You should not need any other context to implement this. + +## Context + +The skill subsystem closes a record → distill → store → **execute** → correct loop. This issue builds the **lowest layer of the execute leg**: the page-driving primitives that everything above (action executor #02, model loop #03, run integration #05) sits on. Today the repo has **zero in-process page-driving primitives** — `backend/agent_server.py` only shells `opencli collect`, and there is no `playwright` / `connect_over_cdp` reference anywhere in the codebase or `pyproject.toml`. The skill channel skeleton (`backend/channels/skill_channel.py`) already acquires a CDP endpoint string from the shared browser pool and stops at a confirm-gate stub; what is missing is the thing that takes that endpoint and actually drives the page. + +This issue implements exactly two ADR decisions: +- **ADR-0003 D1 (Placement — center-side, Playwright over CDP):** add Playwright as a new backend dependency and wrap `chromium.connect_over_cdp(cdp_endpoint)` in a thin `SkillPage` helper exposing the page ops the verb set needs. Local + LAN endpoints only. +- **ADR-0003 D2 (Perception — injected-JS interactive snapshot with refs):** a `snapshot()` that injects JS to tag visible interactive elements with a sequential `data-skill-ref` and returns a compact, **token-bounded** `[{ref, role, name, value}]` list. No raw DOM, no screenshots (the executor model is a small text model, ~32k context). + +No model, no loop, no DB, no events, no run integration in this issue — those are #02–#07. + +## Scope + +**In scope** +- Add `playwright` to `pyproject.toml` `[project].dependencies`. +- `backend/skills/page.py` — `SkillPage` wrapper (+ `open_skill_page(cdp_endpoint)` async factory) around `connect_over_cdp`, exposing the raw page ops the verb set (#02) will call: `goto`, `click(ref)`, `type(ref, text, submit)`, `select(ref, value)`, `scroll(dir)`, `inner_text()`/`extract()`. +- `backend/skills/perception.py` — `snapshot(page) -> list[dict]`: injected-JS ref tagging + the `[{ref, role, name, value}]` projection + element-count cap / pagination. Parsing/projection logic must be pure-Python and unit-testable **without a browser** (the JS-eval boundary is mockable). +- `backend/skills/__init__.py` if the package marker is missing (so `backend.skills.page` / `backend.skills.perception` import). Note: `backend/skills/distill.py` already exists, so the package likely already imports — only add the marker if needed. +- `TESTING.md` — a short note that the skill execute loop needs `playwright install chromium` (Windows dev/CI) and that the loop connects **over CDP** to an already-running `browser_pool` Chrome. + +**Out of scope** (other issues / v2) +- The cheap-model step loop and 9-element prompt → **#03**. +- Action-verb dispatch / risk gate / `red_lines` matching beyond the raw page ops → **#02 / #04**. +- Any run / event / DB integration (`events.emit`, `run_id`, `ChannelResult` plumbing, `awaiting_confirm` status) → **#04 / #05**. +- `journey_trace_v1` emission, re-distill, dock button → **#06**. +- e2e against a real local Chrome behind the `live` marker → **#07** (this issue only needs a parsing unit test in the default suite; a live smoke test here is optional). +- Screenshots, raw-DOM / `outerHTML` dumps, vision models, `evaluate(js)` exposed to the model — **rejected by ADR-0003** (D2/D3), do not add. + +## Depends on + +**None.** This is the first issue in the build order (PRD §9). It does not touch `SkillChannel.collect`, the pipeline, or the DB. + +## Files + +| File | Create / Edit | Purpose (one line) | +|---|---|---| +| `D:/projects/opencli-admin/pyproject.toml` | Edit | Add `playwright>=1.40.0` to `[project].dependencies`. | +| `D:/projects/opencli-admin/backend/skills/page.py` | Create | `SkillPage` + `open_skill_page(cdp_endpoint)` — `connect_over_cdp` wrapper exposing `goto/click/type/select/scroll/inner_text/extract`, all `ref`-addressed. | +| `D:/projects/opencli-admin/backend/skills/perception.py` | Create | `snapshot(page) -> list[dict]` — injected-JS ref tagging + `[{ref,role,name,value}]` projection + element-count cap; pure parsing helper split out for testing. | +| `D:/projects/opencli-admin/TESTING.md` | Edit | Document `playwright install chromium` (Windows) + "loop connects over CDP to a browser_pool Chrome". | +| `D:/projects/opencli-admin/tests/skills/test_perception.py` | Create | Unit test (or doctest) for the pure snapshot-parsing/cap logic; runs under `-m "not live"` with **no browser** (mock the JS-eval boundary). | + +## Implementation notes + +Tie everything to the symbols that already exist in this repo. Do not invent new substrate; do not change `AbstractChannel.collect`. + +### 1. Dependency (`pyproject.toml`) +- Add `"playwright>=1.40.0"` to the `[project].dependencies` array (the same list that currently ends with `"docker>=7.0.0"`). Keep the existing comment-grouped style; a `# Browser automation (skill execute loop over CDP)` comment above it is fine. +- After `pip install -e .` (or `uv sync`), `from playwright.async_api import async_playwright` must import. The Chromium **driver** comes from `playwright install chromium`; the loop connects to an *already-running* Chrome over CDP, so a second browser is not required at runtime — only the driver. Document this in `TESTING.md` (below). + +### 2. `backend/skills/page.py` — the CDP wrapper (ADR D1) +- Async API throughout (the codebase is async: channels, pipeline, `browser_pool.acquire` is an `@asynccontextmanager`). +- The endpoint argument is **exactly the value `browser_pool.get_pool().acquire(endpoint=...)` yields** — a CDP endpoint URL string (see `backend/browser_pool.py`; the skeleton in `backend/channels/skill_channel.py` already does `async with pool.acquire(endpoint=endpoint) as cdp_endpoint:`). Take it as a plain `str`; do **not** acquire the pool slot inside `SkillPage` (the caller owns the slot lifetime). +- Connect with `async_playwright().start()` → `pw.chromium.connect_over_cdp(cdp_endpoint)`. `connect_over_cdp` attaches to the **existing** browser context, so a logged-in page (site cookies already in that Chrome) is reused — same substrate the opencli channel relies on. Pick the existing context/page when present (`browser.contexts[0]` → its first `page`, else `new_page()`); only create a context/page if none exists. +- Shape it as a class plus a factory: + - `class SkillPage` holding the Playwright handle, browser, and active `page`. + - `async def open_skill_page(cdp_endpoint: str) -> SkillPage` factory that does the connect. + - Provide `async def aclose(self)` and make it usable as an async context manager (`__aenter__`/`__aexit__`) so the loop (#03) can `async with open_skill_page(ep) as sp:`. On close, `await browser.close()` then `await pw.stop()` — but do **not** close the underlying Chrome owned by the pool; closing the *connection* is enough. (Prefer disconnecting the CDP connection over killing the browser; if Playwright's `connect_over_cdp` browser `.close()` would terminate the shared Chrome, use context/page cleanup instead and just `pw.stop()`.) +- Page ops (these are the raw primitives #02's verb dispatcher will call — keep them dumb, one Playwright action each): + - `async def goto(self, url: str)` → `await page.goto(url)`; return when navigation settles (default `wait_until` is fine). Must navigate and return without raising given a live endpoint. + - `async def click(self, ref: str)` → resolve the element by its `data-skill-ref` attribute (`page.locator(f'[data-skill-ref="{ref}"]')`) and `.click()`. + - `async def type(self, ref: str, text: str, submit: bool = False)` → locate by ref, `.fill(text)` (or `.click()` then `.type(text)` if a real keystroke stream is needed), and if `submit` press `Enter`. + - `async def select(self, ref: str, value: str)` → locate by ref, `.select_option(value)`. + - `async def scroll(self, direction: str)` → `page.evaluate` a `window.scrollBy(0, ±viewport)` (down/up). This is the **only** internal `evaluate` use besides perception; it is **not** exposed to the model (ADR D3 forbids a model-facing `evaluate(js)`). + - `async def inner_text(self)` / `async def extract(self)` → return visible page text (e.g. `await page.inner_text("body")`) for the `extract` verb. Keep it text, not HTML. +- `ref` resolution is the contract between this wrapper and perception: a `ref` is the `N` that `snapshot()` wrote as `data-skill-ref="N"`. Resolve strictly by that attribute so a stale ref fails loudly rather than clicking the wrong element. +- Module docstring should state: connects over CDP to a `browser_pool` Chrome (local/LAN only), reuses existing logged-in context, no model-facing JS escape hatch. + +### 3. `backend/skills/perception.py` — the snapshot (ADR D2) +- Public: `async def snapshot(page, *, max_elements: int = DEFAULT_MAX_ELEMENTS) -> list[dict]`. + - Inject one JS string via `await page.evaluate(JS)` that: + 1. selects visible `a, button, input, select, [role]` (skip hidden / zero-size / `display:none`), + 2. assigns each a sequential `data-skill-ref="0"`, `"1"`, … **in the DOM**, + 3. returns a compact list of `{ref, role, name, value}` where `role` = tag or ARIA role, `name` = accessible name (text / `aria-label` / `placeholder` / `value` fallback), `value` = current value for inputs/selects (empty string otherwise). + - Each returned dict has **exactly** the keys `ref`, `role`, `name`, `value` (no extras) — this is asserted by #03's prompt builder and by the acceptance test. + - `ref` in the returned dict must equal the `data-skill-ref` written in the DOM (so #02's `click(ref)` resolves the same element). +- **Token bound (hard requirement, ADR D2 + PRD §7 "Token blow-up on huge pages"):** + - Cap the returned list at `max_elements` (define `DEFAULT_MAX_ELEMENTS` as a module constant; **document the default in the docstring** — pick a sane value, ~50, small enough to stay well under the ~32k cheap-model context). Truncate deterministically (first N in DOM order); reaching more elements is the `scroll` verb's job, not a bigger snapshot. + - **Never** return `outerHTML` / raw DOM / a screenshot. Only the projected `[{ref,role,name,value}]` list crosses the boundary. +- **Testability split (so the default suite needs no browser):** factor the pure transform out of the I/O. Concretely, have the injected JS return a raw list and do the **cap + key-normalization + shape validation in Python** in a separate pure function, e.g. `project_snapshot(raw: list[dict], max_elements: int) -> list[dict]`. Then `snapshot(page)` = `project_snapshot(await page.evaluate(JS), max_elements)`. The unit test exercises `project_snapshot` directly (and/or calls `snapshot` with a fake `page` whose `evaluate` is an `AsyncMock` returning canned raw rows) — no Playwright, no Chrome. This mirrors how `backend/skills/distill.py` keeps parsing pure (`extract_json`, `to_skill_fields`) and DB/FS-free. + +### 4. `tests/skills/test_perception.py` +- `tests/skills/` may not exist yet — create it (the PRD reserves `tests/skills/` for #07's live e2e; a non-live unit test lives here too). Add `tests/skills/__init__.py` if the test layout needs it. +- Assert, with **no browser**: + - `project_snapshot` returns dicts whose keys are exactly `{"ref","role","name","value"}`. + - `ref` values are sequential and match the input rows' assigned refs. + - the list is capped at `max_elements` when given more rows than the cap. + - the output contains no `html`/`outerHTML`/screenshot key. +- Optionally a `snapshot(fake_page)` test using `unittest.mock.AsyncMock` for `page.evaluate` (the project already uses `pytest-asyncio` with `asyncio_mode = "auto"`). +- This test must run and pass under the default invocation `pytest -m "not live"` (the suite enforces `--cov-fail-under=80`; keep the new modules covered by exercising `project_snapshot` and the page wrapper's pure bits, or the live-only Playwright lines will drag coverage — see "coverage" below). + +### 5. `TESTING.md` +- Append a short subsection (Chinese is fine to match the file; e.g. "## Skill 执行回路(CDP 浏览器驱动)") noting: + - `playwright install chromium` is required for the skill execute loop on Windows (`win32` dev/CI). + - the loop **connects over CDP** (`connect_over_cdp`) to an already-running Chrome supplied by `browser_pool` (the same Chrome the existing Tests 1–10 start with `--remote-debugging-port=9222`), so no second browser is needed at runtime — only the Playwright driver. + - the browser-dependent path is gated behind the existing `live` pytest marker; the default `pytest -m "not live"` does not need a browser. + +### Coverage note (don't break `--cov-fail-under=80`) +`pyproject.toml` runs `--cov=backend --cov-fail-under=80`. Pure functions (`project_snapshot`, ref-resolution helpers) are unit-tested here. The Playwright-touching lines in `page.py` / `snapshot()`'s `evaluate` call are only reachable with a real browser; if they pull total coverage under 80, either (a) keep those branches thin and exercise them with an `AsyncMock` `page`, or (b) add the new browser-only modules' live-only lines to `[tool.coverage.run] omit` **only if** mocking can't cover them — prefer mocking. Do not lower the global threshold. + +## Acceptance criteria + +Falsifiable; run from the repo root `D:/projects/opencli-admin`. + +1. **Dependency present + importable.** `playwright` appears in `pyproject.toml` `[project].dependencies`, and after install `python -c "from playwright.async_api import async_playwright; print('ok')"` prints `ok` (no ImportError). +2. **`SkillPage` connects over CDP.** `backend/skills/page.py` defines an async `SkillPage` and an `open_skill_page(cdp_endpoint)` factory that calls `chromium.connect_over_cdp(cdp_endpoint)` and exposes `goto(url)`, `click(ref)`, `type(ref, text, submit)`, `select(ref, value)`, `scroll(dir)`, and `inner_text()`/`extract()`. Static check: `python -c "import inspect, backend.skills.page as p; assert all(hasattr(p.SkillPage, m) for m in ['goto','click','type','select','scroll','inner_text','extract']); assert inspect.iscoroutinefunction(p.open_skill_page)"`. +3. **Snapshot shape + ref tagging.** `backend/skills/perception.py` exposes `snapshot(page) -> list[dict]` where **every** dict has exactly the keys `ref, role, name, value`; visible interactive elements get a sequential `data-skill-ref="N"` set in the DOM and the returned `ref` equals that `N`. Verified by the unit test against `project_snapshot` (and/or a mocked `page`). +4. **Token-bounded, no raw DOM/screenshots.** `snapshot()` caps the returned element count at a configurable limit with a documented default (`DEFAULT_MAX_ELEMENTS`), and returns neither `outerHTML`/raw DOM nor a screenshot. Verified by the cap test and by a `grep` showing no `outerHTML` / screenshot return path in `perception.py`. +5. **Non-live unit test, browser-free.** `pytest tests/skills/test_perception.py -m "not live"` passes **without a browser** (the JS-eval boundary is mocked / the pure transform is tested directly). The full default suite `pytest -m "not live"` still passes and still meets `--cov-fail-under=80`. +6. **TESTING.md updated.** `TESTING.md` documents `playwright install chromium` and states that the loop connects over CDP to a `browser_pool` Chrome. +7. **(Optional, manual) Live smoke against real Chrome.** With a Chrome started per `TESTING.md` (`--remote-debugging-port=9222`), a throwaway script: `open_skill_page("http://127.0.0.1:9222")` → `await sp.goto("https://example.com")` → `await snapshot(sp.page)` returns a non-empty `[{ref,role,name,value}]` list and the DOM shows `data-skill-ref` attributes. (Full automation of this is #07 under the `live` marker; not required to close this issue.) + +## Out of scope / non-goals + +- **No model, no loop, no prompt.** The cheap-model step loop, the 9-element system prompt, and the OpenAI/Qwen tool-calling harness are **#03**. This issue ships only the page + perception primitives they call. +- **No action dispatch / risk logic.** Mapping the fixed verb set to these ops, `red_lines` / high-risk pattern matching, `auto_confirm`, and the `awaiting_confirm` status are **#02 / #04**. `SkillPage` exposes raw ops only; it makes **no** risk decisions. +- **No run / event / DB plumbing.** Do not touch `SkillChannel.collect`, `backend/pipeline/pipeline.py`, `backend/pipeline/runner.py`, `backend/pipeline/events.py::emit`, `ChannelResult` metadata, or any migration. Those are **#05 / #04**. +- **No record leg, no re-distill, no trace.** `journey_trace_v1` emission and re-distill (`backend/skills/trace.py`, `correction.py`, dock "重蒸技能") are **#06**. +- **No NAT/edge execution.** Local + LAN CDP endpoints only (ADR D1); driving NAT edge nodes via `agent_server` is v2. +- **Rejected by ADR-0003 — do not add:** raw-DOM/`outerHTML` dumps, screenshots, vision-model perception, or a model-facing `evaluate(js)` escape hatch (D2/D3). The single internal `evaluate` for scroll/snapshot stays server-side and is never surfaced to the model. diff --git a/docs/skills-issues/02-action-executor-fixed-verb-set-to-playwright-ops-w.md b/docs/skills-issues/02-action-executor-fixed-verb-set-to-playwright-ops-w.md new file mode 100644 index 00000000..d0d399e1 --- /dev/null +++ b/docs/skills-issues/02-action-executor-fixed-verb-set-to-playwright-ops-w.md @@ -0,0 +1,149 @@ +# 02 Action executor: fixed verb set to Playwright ops with ref resolution + +> Self-contained build unit. Implementable in a fresh session from this file + `docs/adr/0003-skill-execute-loop-architecture.md` (ADR-0003) + `docs/skills-execute-loop-PRD.md` alone. Repo root: `D:/projects/opencli-admin`. + +## Context + +The skill subsystem closes a **record → distill → store → execute → correct** loop. The **execute** leg is a `skill` channel where a *cheap* text model (e.g. `qwen3:4b`) drives a real Chrome page step by step: **perceive → propose → confirm → act**. This issue builds the **act** primitive — the deterministic layer that takes *one* structured action the model already chose and performs it on the page, returning a structured result. It implements ADR-0003 decision **D3 (Action space: small fixed verb set, ref-addressed)** and is the executor the step loop (issue 03) calls once per step. It must obey the safety constraint baked into D3: **no `evaluate(js)` escape hatch** — arbitrary JS is the uncontrollable red line and never reaches the model. It is consumed downstream by D4 (the risk gate, issue 04) and D5 (run integration, issue 05); `extract` results become `ChannelResult.items` and `done` ends the loop. This issue does **not** decide *which* action to run (that is the model loop, issue 03) and does **not** classify risk or touch events/DB. + +## Scope + +**In scope** +- New module `backend/skills/actions.py`: + - An action **schema/validator** for the exact 7 verbs `{navigate, click, type, select, scroll, extract, done}`, rejecting any other verb (including `evaluate`/`js`) with a structured error rather than raising. + - `execute_action(page, snapshot, action)` — async dispatch that maps one validated action onto a `SkillPage` (the CDP/Playwright wrapper from issue 01). + - **ref → element resolution**: resolve `action["ref"]` against the current `snapshot` (the `data-skill-ref` interactive list from issue 01) *before* acting; a missing/stale/out-of-range ref returns a structured error result. + - **per-action result objects**: every call returns a uniform structured result (`ok`/`error`, the echoed verb, any extracted record, a `terminal` flag), never an exception for the expected failure cases (unknown verb, bad ref, missing field). + - `extract{data}` → **record mapping**: returns a result carrying a record dict destined for `ChannelResult.items`; performs **no page write**. + - `done{status,note}` → **terminal signal**: returns a result distinctly flagged as terminal. +- New test `tests/skills/test_actions.py` driving `execute_action` against a **fake/mock `SkillPage`** for every verb (happy path + bad-ref + unknown-verb), passing in the default `-m "not live"` suite. + +**Out of scope** (deferred to other issues / v2) +- **Deciding which action to run** — the cheap-model step loop, the 9-element prompt, the tool-calling harness (OpenAI `tool_calls` + Qwen XML). That is **issue 03** (`backend/skills/loop.py`, `backend/skills/prompt.py`). +- **The risk gate / confirm tiering** (`red_lines`, high-risk pattern `submit|pay|post|delete`, `auto_confirm`, `awaiting_confirm`). That is **issue 04** (`backend/skills/risk.py`). The executor just runs whatever validated action it is handed; gating happens *before* `execute_action` is called. +- **Events / DB / `ChannelResult` wiring / pipeline integration.** The executor never calls `events.emit`, never opens a DB session, never builds a `ChannelResult`. Mapping the executor's `extract` record into `ChannelResult.items` and the terminal flag into loop termination is **issue 05** (`backend/channels/skill_channel.py`, `backend/pipeline/pipeline.py`, `backend/pipeline/runner.py`). +- **The CDP page wrapper and perception snapshot themselves** — `SkillPage` (`backend/skills/page.py`) and the injected-JS snapshot (`backend/skills/perception.py`). Those are **issue 01** (this issue's dependency). This issue *consumes* their contract; it does not implement Playwright/`connect_over_cdp` or the injected JS. +- **No new verbs beyond the fixed 7; no `evaluate(js)` / raw-DOM / screenshot path** (ADR-0003 D2/D3 red line). +- **`journey_trace_v1` trace assembly** (issue 06) — the executor returns per-action results; turning a sequence of them into a trace is not here. + +## Depends on + +- **01 — Playwright dep + CDP page wrapper + perception snapshot** (`backend/skills/page.py`, `backend/skills/perception.py`, `pyproject.toml`, `TESTING.md`). Issue 02 imports the `SkillPage` type from issue 01 (for typing only) and is written against the **method contract** and **snapshot shape** issue 01 defines. The unit tests here fake `SkillPage`, so issue 02 is testable without a real browser, but the production dispatch targets issue 01's real methods. + +### Contract issue 02 assumes from issue 01 (verify against `backend/skills/page.py` / `backend/skills/perception.py` when 01 lands) + +If issue 01's names differ, adapt the dispatch in `actions.py` to the actual symbols and keep this section as the rationale. + +- **Snapshot shape** (ADR-0003 D2, `docs/GLOSSARY.md` "ref"): a list of dicts `[{ "ref": , "role": str, "name": str, "value": str }, ...]`. `ref` is the per-snapshot `data-skill-ref` id. Ref resolution = locate the snapshot entry whose `ref` equals the action's `ref` (compare as strings to be tolerant of int/str). "Resolution" here is membership/validity in the current snapshot; the actual element lookup on the page is done by `SkillPage` *by ref* (it set `data-skill-ref` during perception). So the executor validates the ref against the snapshot, then passes the ref through to the matching `SkillPage` method. +- **`SkillPage` async methods** (the verb → op map; ADR-0003 D1/D3, PRD §4 D1): + - `await page.goto(url)` — for `navigate{url}`. + - `await page.click(ref)` — for `click{ref}`. + - `await page.type(ref, text, submit=False)` — for `type{ref,text,submit?}`; `submit=True` triggers an Enter/submit via the wrapper, `submit` omitted/false does not. + - `await page.select(ref, value)` — for `select{ref,value}`. + - `await page.scroll(dir)` — for `scroll{dir}` (`dir` ∈ `{up, down}` at minimum; pass through and let `SkillPage` clamp). + - `extract{data}` does **not** call a write method. `data` is the model-supplied record dict; the executor returns it as the record. (If issue 01 exposes a read helper like `page.inner_text(ref)` for value pull-through, it may be used to enrich `data` — optional, keep `extract` a pure read.) + - `done{status,note}` calls **no** page method. + +## Files + +| File | Create/Edit | Purpose (one line) | +|---|---|---| +| `D:/projects/opencli-admin/backend/skills/actions.py` | **Create** | Fixed 7-verb action schema/validator + `execute_action(page, snapshot, action)` dispatch with ref resolution, per-action structured results, `extract`→record, `done`→terminal. | +| `D:/projects/opencli-admin/tests/skills/test_actions.py` | **Create** | Unit tests driving `execute_action` against a fake `SkillPage` for every verb (happy + bad-ref + unknown-verb), runnable under `-m "not live"`. | +| `D:/projects/opencli-admin/tests/skills/__init__.py` | **Create** | Make `tests/skills/` a package (the existing suite uses package dirs, e.g. `tests/unit/channels/__init__.py`). Empty file. | + +> Note: existing tests live under `tests/unit/...` and `tests/integration/...`. This feature's PRD fixes the skill test path as `tests/skills/...`; create that directory by writing the files above. `testpaths = ["tests"]` in `pyproject.toml` already collects it. + +## Implementation notes + +Tie everything to this codebase's existing symbols. **Honor the fixed decisions** — do NOT change `AbstractChannel.collect(config, parameters)` (`backend/channels/base.py`); reuse the spine; do not add verbs; do not add a JS escape hatch. + +1. **Module shape — pure-ish, no I/O of its own.** `actions.py` imports nothing from `backend.pipeline`, `backend.database`, or `backend.models`. It may `from typing import TYPE_CHECKING` and import `SkillPage` only under `TYPE_CHECKING` (issue 01's `backend/skills/page.py`) so the module loads even before a browser/Playwright is present and so the unit test can pass a fake. The only async surface is `execute_action`; validation is a sync pure function. Mirror the existing pure-function + async-dispatch split already used in `backend/channels/opencli_channel.py` (pure `_parse_*` helpers tested directly, async `collect`/`_run_opencli` tested with mocks) and `backend/skills/distill.py` (pure `extract_json`/`slug`, async `call_llm`/`distill_trace`). + +2. **Fixed verb set as the single source of truth.** Define a module-level constant of the 7 allowed verbs and their required/optional fields, e.g.: + ```python + VERBS = { + "navigate": {"required": ("url",), "optional": ()}, + "click": {"required": ("ref",), "optional": ()}, + "type": {"required": ("ref", "text"), "optional": ("submit",)}, + "select": {"required": ("ref", "value"), "optional": ()}, + "scroll": {"required": ("dir",), "optional": ()}, + "extract": {"required": ("data",), "optional": ()}, + "done": {"required": ("status",), "optional": ("note",)}, + } + ``` + This is the analogue of `WRITE_TOOLS` / `TOOLS` in `backend/api/v1/chat.py` but it is the **skill loop's own** schema — do **not** import or overload the chat-console `TOOLS`/`WRITE_TOOLS` (PRD §4 D3, §6 "Tool-calling harness"). Provide a `validate_action(action: dict) -> str | None` that returns an error string (or `None` when valid): unknown/missing `verb`, a verb not in `VERBS` (explicitly including `evaluate`/`js`/anything else), and missing required fields each yield a distinct message. + +3. **Structured result objects.** Define one result constructor used everywhere — a small dataclass or a plain dict factory; match the project's lightweight style (`ChannelResult` in `backend/channels/base.py` is a `@dataclass` with `ok`/`fail` classmethods — mirror that). Suggested shape: + ```python + @dataclass + class ActionResult: + ok: bool + verb: str | None = None + error: str | None = None + record: dict | None = None # only set by extract + terminal: bool = False # only True for done + detail: dict = field(default_factory=dict) # e.g. {"status","note"} for done, {"ref"} acted on + ``` + Provide `ActionResult.success(verb, **kw)` and `ActionResult.failure(verb, error)` classmethods. **Never raise for the expected failure cases** (unknown verb, bad/stale ref, missing field, page-op error) — convert them to `ActionResult.failure(...)`. Wrap each `await page.*` call in `try/except` and turn an unexpected `Exception` into a structured error (same best-effort discipline as `events.emit` in `backend/pipeline/events.py`, which never raises). + +4. **Ref resolution before acting.** Add a pure helper `resolve_ref(snapshot, ref) -> dict | None` that returns the snapshot entry matching `ref` (string-compare to tolerate int/str), or `None`. In `execute_action`, for the ref-addressed verbs (`click`, `type`, `select`), call it first; on `None` return `ActionResult.failure(verb, f"stale/unknown ref: {ref!r}")` **before** touching the page. This is the "invalid/stale ref returns a structured error rather than raising" guarantee. `navigate`, `scroll`, `extract`, `done` are not ref-addressed and skip resolution. + +5. **Dispatch (`execute_action`).** Order: `validate_action` → (for ref verbs) `resolve_ref` → call the matching `SkillPage` method → build the result. + - `navigate` → `await page.goto(action["url"])` → `ActionResult.success("navigate", detail={"url": ...})`. + - `click` → `await page.click(ref)` → success. + - `type` → `await page.type(ref, action["text"], submit=bool(action.get("submit", False)))`. **`submit:true` must trigger an Enter/submit via the wrapper; `submit` omitted/false must not** (acceptance #4). Pass the flag through to `SkillPage.type`; the actual key press lives in `SkillPage` (issue 01) — the executor's job is to pass `submit` correctly and not invent its own page op. + - `select` → `await page.select(ref, action["value"])` → success. + - `scroll` → `await page.scroll(action["dir"])` → success. + - `extract` → **no page write**; return `ActionResult.success("extract", record=dict(action["data"]))`. The record is destined for `ChannelResult.items` (issue 05 appends `result.record` to the items list). Copy the dict so the caller can't mutate the action. + - `done` → **no page call**; return `ActionResult.success("done", terminal=True, detail={"status": action["status"], "note": action.get("note")})`. This is the **distinctly-flagged terminal** result (acceptance #3): `terminal=True` and no other verb sets it. + +6. **What downstream issues do with the result (do not implement here, just keep the shape compatible).** + - Issue 03's loop calls `execute_action` once per model step; reads `result.terminal` to stop, and `result.record` to accumulate. + - Issue 04's risk gate runs **before** `execute_action`; it decides confirm/auto-run and never relies on the executor for risk. + - Issue 05 maps `result.record` → `ChannelResult.items` (via `ChannelResult.ok(items, ...)` in `backend/channels/base.py`) and emits per-step `TaskRunEvent`s via `backend/pipeline/events.py::emit(run_id, step, ...)` (`step` values like `skill_step`, `skill_extract`, `skill_done`). The executor itself stays out of `events`/DB. + +7. **Tests (`tests/skills/test_actions.py`).** Follow the existing mock style (`from unittest.mock import AsyncMock, MagicMock` as in `tests/unit/channels/test_opencli_channel.py`). `asyncio_mode = "auto"` is set in `pyproject.toml`, so `async def test_*` need no decorator. Build a **fake `SkillPage`**: a `MagicMock` whose `goto/click/type/select/scroll` are `AsyncMock`s; assert both the returned `ActionResult` and that the right method was awaited with the right args (e.g. `page.type.assert_awaited_once_with("3", "hello", submit=True)`). Use a small fixed snapshot, e.g. `SNAP = [{"ref": "1", "role": "button", "name": "Search", "value": ""}, {"ref": "3", "role": "textbox", "name": "q", "value": ""}]`. Required cases (acceptance #5): + - **Happy path per verb** (7): navigate, click, type (with and without `submit`), select, scroll, extract (asserts `result.record` returned and **no write method called**), done (asserts `result.terminal is True`). + - **bad-ref**: `click`/`type`/`select` with a ref not in the snapshot → `result.ok is False`, error mentions the ref, and the page method was **not** awaited. + - **unknown-verb**: `{"verb": "evaluate", "js": "..."}` and `{"verb": "frobnicate"}` → `result.ok is False`, distinct error, no page call. (Explicitly assert `evaluate`/`js` is rejected — the ADR red line.) + - **submit toggle**: separate assertions that `submit:true` passes `submit=True` and omitting it passes `submit=False`. + - Keep these as pure unit tests (no DB, no `client` fixture, no `live` marker) so they run in the default `pytest -m "not live"` collection. + +## Acceptance criteria + +Falsifiable. Run from repo root `D:/projects/opencli-admin` (activate the project venv; dev extras from `pyproject.toml [project.optional-dependencies] dev`). + +1. **Fixed verb set + reject others.** `backend/skills/actions.py` defines exactly `{navigate, click, type, select, scroll, extract, done}` and `validate_action` (or `execute_action`) **rejects any other verb — including `evaluate`/`js` — with a structured error**, never a raise. Verify: + ```bash + python -c "from backend.skills.actions import VERBS, validate_action; \ + assert set(VERBS) == {'navigate','click','type','select','scroll','extract','done'}, VERBS; \ + assert validate_action({'verb':'evaluate','js':'x'}); \ + assert validate_action({'verb':'frobnicate'}); \ + assert validate_action({'verb':'navigate'}); \ + assert validate_action({'verb':'navigate','url':'http://x'}) is None; \ + print('verbset-ok')" + ``` + (Adjust the import names to the final symbols, but the verb set and the evaluate-rejection are non-negotiable.) +2. **Ref resolution → structured error, no raise.** `execute_action` resolves `{ref}` against the snapshot and calls the matching `SkillPage` method; an invalid/stale ref returns a failure result and the page method is **not** awaited. This is asserted by the bad-ref tests below; `execute_action` must not propagate an exception for a stale ref. +3. **`extract` → record, no write; `done` → terminal.** `extract{data}` returns a result whose record is a dict (destined for `ChannelResult.items`) and performs **no** page write (no `goto/click/type/select/scroll` awaited). `done{status,note}` returns a result with a distinct terminal flag (`terminal=True`) that no non-terminal verb sets. Asserted by the extract/done tests. +4. **`type` submit toggle.** `type{ref,text,submit:true}` triggers a submit/Enter via the wrapper (the executor calls `page.type(ref, text, submit=True)`); `submit` omitted/false calls it with `submit=False`. Asserted by two test cases. +5. **Tests pass in the default suite.** `tests/skills/test_actions.py` drives `execute_action` against a fake/mock `SkillPage` for **every** verb (happy path + bad-ref + unknown-verb) and passes with the browser-requiring tests deselected: + ```bash + pytest tests/skills/test_actions.py -m "not live" -p no:cacheprovider -q + ``` + All tests pass with no real browser and no network. (The default `addopts` enforces `--cov=backend --cov-fail-under=80`; if running only this file trips the global coverage gate, run with `--no-cov` for the isolated check, but the file must pass as part of the full `pytest -m "not live"` run.) +6. **Lint clean.** `ruff check backend/skills/actions.py tests/skills/test_actions.py` passes (repo selects `E,F,I,N,W,UP`, line-length 100 — see `pyproject.toml [tool.ruff]`). + +### Verifying against a real local Chrome (optional, not required to close this issue) +This issue's deliverable is unit-testable without a browser; ref resolution and dispatch are proven against a fake `SkillPage`. The *real* page-driving is issue 01's `SkillPage` and is exercised end-to-end by the **issue 07** e2e test (behind the `live` marker — see `pyproject.toml [tool.pytest.ini_options] markers`, deselected by `-m "not live"`). If you want a manual smoke once issue 01 has landed: from a Python REPL, acquire a CDP endpoint via `backend.browser_pool.get_pool().acquire(...)`, build a real `SkillPage`, take a snapshot via the issue-01 perception fn, then call `await execute_action(page, snapshot, {"verb":"navigate","url":"https://example.com"})` and `{"verb":"click","ref":}` and confirm the page reacts and a structured `ActionResult` returns. Do **not** add this to the default suite — it requires a running Chrome (ADR D8, PRD §7 "Windows Playwright install"). + +## Out of scope / non-goals + +- Choosing which action to emit (issue 03 loop + prompt + tool-calling harness). +- Risk classification / confirm tiering / `auto_confirm` / `awaiting_confirm` (issue 04). +- `ChannelResult` construction, `events.emit`, DB, and pipeline wiring (issue 05); `journey_trace_v1` assembly (issue 06); e2e against real Chrome (issue 07). +- Implementing `SkillPage` / the perception snapshot / Playwright / `connect_over_cdp` (issue 01). +- Any new verb, any `evaluate(js)`/raw-DOM/screenshot path, vision models (ADR-0003 D2/D3, hard red line). +- Cross-process pause/resume, auto-triggered re-distill, NAT/edge-node execution (v2; PRD §1 non-goals, §8 cut-line). +- Changing `AbstractChannel.collect` signature or the chat-console `TOOLS`/`WRITE_TOOLS` (reuse the spine; define the skill verb schema separately). diff --git a/docs/skills-issues/03-cheap-model-step-loop-with-9-element-prompt-and-to.md b/docs/skills-issues/03-cheap-model-step-loop-with-9-element-prompt-and-to.md new file mode 100644 index 00000000..a6bce763 --- /dev/null +++ b/docs/skills-issues/03-cheap-model-step-loop-with-9-element-prompt-and-to.md @@ -0,0 +1,134 @@ +# 03 Cheap-model step loop with 9-element prompt and tool-calling harness + +> Self-contained issue. Source of truth: `docs/adr/0003-skill-execute-loop-architecture.md` (decisions **D1–D8**) and `docs/skills-execute-loop-PRD.md` (§4, §6, issue **03** in §9). You should be able to implement this in a fresh session from this file alone. Repo root: `D:/projects/opencli-admin`. + +## Context + +This wires the **perceive → propose → act** loop (ADR **D6**, "Loop control from the 9 elements"; D3 "Action space"; D5 "Run integration") that lets a *cheap* text model (e.g. `qwen3:4b`, ~32k ctx, no vision) drive a real Chrome page **one action per step**. Each step builds a system prompt from the SKILL.md 9 elements (`procedure`, `milestones`, `terminal_conditions`, `false_terminal_states`, `red_lines`) plus the current interactive snapshot (D2), asks the model for **exactly one** action using the tool-calling pattern already proven in the agent dock (`backend/api/v1/chat.py` — OpenAI `tool_calls` for normal models, the Qwen XML `` variant for `qwable`-style models), validates the action against issue **02**'s schema, executes it (issue **02**'s executor over issue **01**'s `SkillPage`), and loops until `done{}` (validated against `terminal_conditions` / `false_terminal_states`) or a `max_steps` cap. This issue is the **brain** of the loop; it stops short of the risk/confirm gate (issue 04) so every action auto-runs and the loop is independently testable with a stubbed model and a fake page. + +## Scope + +**In scope** +- `backend/skills/loop.py` — the step loop: per-step perceive → prompt → model call → parse → execute → feed back; `max_steps` cap; `done{}` validation against `terminal_conditions` / `false_terminal_states`; returns ordered step records + accumulated extract records. +- `backend/skills/prompt.py` — (a) build the step **system prompt** from the 9 elements (`Skill.elements` / inline `skill_md`) + the current `[{ref,role,name,value}]` snapshot; (b) define the **skill verb tool schema** (OpenAI function-shaped, the 7 verbs) **plus** a parallel text description for the XML-tool path — a **separate object** from `chat.py`'s `TOOLS`. +- Reuse (do not fork) the tool-call parsing *pattern* from `backend/api/v1/chat.py` (`_is_xml_tool_model`, `_parse_tool_use`, OpenAI `tool_calls`) to normalize both shapes into one action. +- `tests/skills/test_loop.py` — end-to-end loop test with a stubbed model (scripted tool calls) + a fake `SkillPage`, runnable under `-m "not live"`. + +**Out of scope** (deferred — do **not** build here) +- The **risk / confirm gate** (issue **04**): here **every** action auto-runs. No `awaiting_confirm`, no `Proposal`, no `auto_confirm` branching. +- **Run / event integration & `ChannelResult` assembly** (issue **05**): no `events.emit`, no `SkillChannel.collect` wiring, no `parameters["run_id"]`. The loop returns plain Python data; the caller (issue 05) maps it to events + `ChannelResult`. +- **`journey_trace_v1` emission & re-distill** (issue **06**): the loop returns step records the trace builder will later consume, but it does **not** assemble the trace. +- The risk classifier itself, `awaiting_confirm` run status, the anchor migration — all issue 04. +- Playwright dependency, CDP `SkillPage`, perception snapshot (issue **01**); action executor + ref resolution (issue **02**). This issue **consumes** their interfaces. +- v2: cross-process pause/resume, auto-triggered re-distill, NAT/edge execution, vision/raw-DOM/screenshots, `evaluate(js)`. + +**Hard rule:** Must **NOT** reuse `chat.py`'s `TOOLS` / `WRITE_TOOLS`. The skill loop defines its **own** verb schema and risk-marking. (PRD §4 D3: "do **not** overload the chat-console tools".) + +## Depends on + +- **01** — Playwright dep + CDP page wrapper + perception snapshot (`backend/skills/page.py`, `backend/skills/perception.py`). Provides the `SkillPage` object the loop perceives/acts through, and the `[{ref,role,name,value}]` snapshot shape. +- **02** — Action executor: verb set → Playwright ops, ref resolution (`backend/skills/actions.py`). Provides the action **schema/validator** and the `execute(action, page)`-style entry the loop calls per step. + +> Implementer note: 01 and 02 may not be merged yet when you start. **Depend on their interfaces, not their internals.** Define the loop against a narrow typed boundary (a `SkillPage` Protocol for perceive/act, and a `validate_action` + `execute_action` import from `backend.skills.actions`). If the exact names from 01/02 differ at merge time, adapt the import/adapter only — the loop logic stays. Mirror the names the PRD uses (`SkillPage`, `actions.py`) and the snapshot keys (`ref, role, name, value`) so the seam lines up. The test uses a **fake** `SkillPage` and **scripted** model, so it does not require 01/02 to be present to pass. + +## Files + +| File | Create/Edit | Purpose (one line) | +|---|---|---| +| `backend/skills/prompt.py` | **create** | Build the 9-element + snapshot system prompt; define the skill verb tool schema (OpenAI-shaped) + XML-path text — separate from `chat.py` `TOOLS`. | +| `backend/skills/loop.py` | **create** | The perceive→propose→act step loop: one action/step, normalize OpenAI + XML tool calls, execute, feed back, terminate on `done{}` or `max_steps`; return step records + extracts. | +| `tests/skills/test_loop.py` | **create** | End-to-end loop test: stubbed model (scripted tool calls) + fake `SkillPage`; asserts step ordering, done-validation, cap. Passes under `-m "not live"`. | +| `tests/skills/__init__.py` | **create if missing** | Make `tests/skills` a package (the dir does not exist yet). | + +> Note: `backend/skills/` currently contains only `distill.py` + `__init__.py`. `tests/skills/` does **not** exist yet — creating `tests/skills/test_loop.py` (and `__init__.py`) creates the directory. `pyproject.toml` already sets `testpaths = ["tests"]`, `asyncio_mode = "auto"`, and the `live` marker; **no pyproject edit is needed** for this issue. + +## Implementation notes + +Tie everything to existing symbols. Reuse the spine; honor the fixed decisions. + +### A. `prompt.py` — system prompt from the 9 elements + snapshot + +- **Inputs.** Accept the 9-element source two ways (Skill rows store both): a structured `elements: dict` (the `Skill.elements` JSON — keys per `backend/skills/distill.py::ELEMENT_KEYS`: `preconditions, procedure, milestones, terminal_conditions, false_terminal_states, recovery_policies, anti_drift_boundaries, red_lines`) **and/or** the raw `skill_md: str`. Write a small `build_system_prompt(*, skill_md: str | None, elements: dict | None, snapshot: list[dict], task: str | None, step_index: int, max_steps: int) -> str`. + - The prompt **must** explicitly include, with clear labels: `procedure`, `milestones`, `terminal_conditions`, `false_terminal_states`, `red_lines`. Pull them from `elements` when present; otherwise fall back to embedding `skill_md` verbatim (it already contains them). Prefer structured `elements` so each section is addressable; degrade gracefully if a key is missing/empty. + - State the loop contract in the prompt: "emit **exactly one** tool call per step"; "call `done` only when a `terminal_condition` is met"; "`false_terminal_states` are traps — do **not** `done` in those"; "address elements by `ref` from the snapshot below"; "no JS / no actions outside the verb set". + - Render the snapshot compactly as the `[{ref, role, name, value}]` list (one line per element, e.g. `# "" = `). Keep it token-bounded — the snapshot is already capped by issue 01's perception; do not re-expand it. +- **Skill verb tool schema (the core deliverable of this file).** Define a module-level `SKILL_TOOLS: list[dict]` in **OpenAI function-calling shape** (same shape as `chat.py::TOOLS`, i.e. `{"type":"function","function":{"name","description","parameters":{json-schema}}}`) covering the **7 verbs** from ADR D3 / PRD §4: + - `navigate{url}` · `click{ref}` · `type{ref,text,submit?}` · `select{ref,value}` · `scroll{dir}` (enum e.g. `up|down`) · `extract{data}` (free-form object/record) · `done{status,note}` (`status` e.g. `success|failed|paused`). + - Keep this schema **canonically aligned** with issue 02's action validator — the model proposes these verbs/args, 02 validates and executes them. If 02 exposes a schema/enum, import and reuse it rather than re-declaring divergent JSON. + - Provide a parallel `SKILL_TOOLS_TEXT: str` describing the same 7 verbs for the **XML-tool path** (mirrors `chat.py::XML_TOOL_TEXT`), instructing the model to emit `{json args}` and nothing else. + - Optionally expose a `SKILL_WRITE_VERBS` set (e.g. `{"click","type","select"}` — submit-ish writes) so **issue 04** can hang the risk gate off it. Defining the set here is fine; **using** it to gate is out of scope. Do not import `chat.py::WRITE_TOOLS`. + +### B. `loop.py` — the step loop + +- **Signature (suggested):** `async def run_skill_loop(*, page: SkillPage, model_call, skill_md=None, elements=None, task=None, max_steps=20) -> LoopResult`. Keep it **pure of the spine**: no `events.emit`, no DB, no `ChannelResult`. Inject the model via a callable so the test can script it (see below). + - `SkillPage` boundary: define a `typing.Protocol` (or import from issue 01 once present) covering what the loop needs — `await page.snapshot() -> list[{ref,role,name,value}]` (perceive) and whatever 01 exposes for current URL/title used in step records. **Actions go through issue 02's executor**, not directly on the page, so the loop stays thin. + - `model_call` boundary: an `async (messages: list[dict], *, tools, model, xml: bool) -> RawModelReply` callable. In production this wraps the provider client. **Do not** hardcode a provider here — the executor model config arrives the same way `distill`'s does, via `backend/skills/distill.py::provider_from_model(mp)` (returns `{base_url, model, api_key, api_style, timeout}`). The loop receives the resolved `model` name + a `model_call` already bound to that provider; provider **resolution** is the caller's job (issue 05). Use `_is_xml_tool_model(model)` (reused from `chat.py`) to decide the OpenAI vs XML path. +- **Per-step algorithm:** + 1. `snapshot = await page.snapshot()` (perceive — D2). + 2. `system = build_system_prompt(...)`; assemble `messages` (system + a running transcript of prior `action -> result` turns so the model has context; keep it bounded). + 3. Call the model for **one** action: + - Normal model: `tools=SKILL_TOOLS, tool_choice="auto"`; read `response.choices[0].message.tool_calls`. Reuse the OpenAI `tool_calls` reading pattern from `chat.py::chat`. + - XML model (`_is_xml_tool_model(model)` true): put `SKILL_TOOLS_TEXT` in the system prompt; parse `` from message content with the **same regex/parser pattern** as `chat.py` (`_parse_tool_use`, `_TOOL_USE_RE`, strip `` via `_THINK_RE`). Reuse `_safe_json` for arg parsing. + - **Normalize both into one `Action`** = `(verb: str, args: dict)`. If the model returns >1 tool call, take the **first** and ignore the rest (one action/step is the contract; record that you truncated). + 4. **Validate** the action via issue 02 (`actions.validate_action(action)` or equivalent). On invalid: record an error step, feed the validation error back to the model as the step result, and continue (do not crash). This keeps a confused cheap model recoverable. + 5. If `verb == "done"`: run **done-validation** (see C) and **terminate** — do not execute it as a page op. + 6. Otherwise **execute** via issue 02 (`await actions.execute_action(action, page)` or equivalent). Capture its result/error. + 7. If `verb == "extract"`: append the extracted payload to an `extracts: list[dict]` accumulator (this is what issue 05 will surface as `ChannelResult.items`). + 8. Append a **step record** (ordered) — at least `{index, verb, args, ref/target, snapshot_digest, result, error, elapsed_ms}` — and feed `action + result` back into the transcript for the next step. + 9. Loop until `done` or `index == max_steps`. +- **Termination & return.** + - **`max_steps`**: default **`MAX_STEPS = 20`** as a module constant (document it; PRD §4 D6 says "~20"). When hit without `done`, terminate with outcome `capped`. + - **`done{}` validation (C):** when the model emits `done{status,note}`, check the claimed completion against the 9 elements: it is only an **accepted** done if it does **not** match any `false_terminal_states` entry and (best-effort) is consistent with `terminal_conditions`. Since the executor is a cheap model and conditions are NL, keep this a **conservative string/heuristic check** (e.g. flag a done whose `note`/current snapshot trips a `false_terminal_states` phrase → mark `done_rejected`, feed the rejection back, and continue the loop instead of stopping). Record `terminal_check: accepted|rejected` on the done step. Do **not** silently trust `done`. + - **Return** a `LoopResult` (dataclass) with: `steps: list[StepRecord]` (ordered), `extracts: list[dict]`, `outcome: str` (`done_success | done_failed | capped | error`), and a small `summary` (e.g. milestones-hit best-effort, final status, step count). This is the raw material issues 05 (`ChannelResult`) and 06 (`journey_trace_v1`) consume — shape it forward-compatibly (plain dict-able). +- **Reuse, don't fork.** Import the parsing helpers from `backend.api.v1.chat` (`_is_xml_tool_model`, `_parse_tool_use`, `_safe_json`, and the `_TOOL_USE_RE`/`_THINK_RE` if needed). If a clean import is awkward (they're underscore-private), it is acceptable to lift a *tiny* shared helper into a small module and import it from both — but the **default is reuse**; do not duplicate the regex logic. Do not change `chat.py`'s behavior. +- **Do not touch fixed seams.** Do **not** change `AbstractChannel.collect(config, parameters)` (`backend/channels/base.py`) — that is issue 05's integration surface and its signature is frozen (ADR D5). Do **not** call `browser_pool.acquire` here (issue 05 owns acquisition; the loop receives an already-bound `SkillPage`). Do **not** call `events.emit` here (issue 05). + +### C. Symbols you will touch / reuse (quick map) + +- Reuse pattern from `backend/api/v1/chat.py`: `TOOLS`-shape (as a template only), `_is_xml_tool_model`, `XML_TOOL_MODELS = ("qwable",)`, `_parse_tool_use`, `_TOOL_USE_RE`, `_THINK_RE`, `_safe_json`, and the OpenAI `tool_calls` loop in `chat()`. **Do not** import/reuse `TOOLS` or `WRITE_TOOLS` as the verb set. +- Reuse from `backend/skills/distill.py`: `provider_from_model(mp)` (provider config shape `{base_url, model, api_key, api_style, timeout}`) — referenced for how the executor model is resolved by the caller; `ELEMENT_KEYS` (the 9-element dict keys). +- Source of the 9 elements: `backend/models/skill.py::Skill.elements` (JSON) + `Skill.skill_md` (Text). +- Consume from issue 01: `SkillPage` (perceive `snapshot()`), snapshot keys `ref, role, name, value`. +- Consume from issue 02: action **schema/validator** + **executor** (`backend/skills/actions.py`). + +## Acceptance criteria + +Falsifiable. Run from repo root `D:/projects/opencli-admin` unless noted. The default suite enforces `--cov-fail-under=80` (per `pyproject.toml`), so the new code must be exercised by `test_loop.py`. + +1. **9-element prompt.** `backend/skills/prompt.py::build_system_prompt(...)` returns a string that, given a `Skill.elements`-shaped dict (or inline `skill_md`) plus a `[{ref,role,name,value}]` snapshot, **contains** `procedure`, `milestones`, `terminal_conditions`, `false_terminal_states`, and `red_lines` content, **and** renders each snapshot element's `ref`. Verifiable: + ```bash + python -c "from backend.skills.prompt import build_system_prompt; \ + els={'procedure':['p1'],'milestones':['m1'],'terminal_conditions':['tc1'],'false_terminal_states':['fts1'],'red_lines':['rl1']}; \ + snap=[{'ref':'0','role':'button','name':'Search','value':''}]; \ + s=build_system_prompt(skill_md=None, elements=els, snapshot=snap, task='t', step_index=0, max_steps=20); \ + assert all(k in s for k in ['p1','m1','tc1','fts1','rl1','0','Search']), s; print('OK')" + ``` +2. **Separate verb schema, 7 verbs, two shapes.** `prompt.py` exposes `SKILL_TOOLS` (OpenAI function-shape) covering exactly the 7 verbs `navigate, click, type, select, scroll, extract, done`, **and** a `SKILL_TOOLS_TEXT` string for the XML path; `SKILL_TOOLS` is a **distinct object** from `backend.api.v1.chat.TOOLS`. Verifiable: + ```bash + python -c "from backend.skills.prompt import SKILL_TOOLS, SKILL_TOOLS_TEXT; \ + from backend.api.v1.chat import TOOLS as CHAT_TOOLS; \ + names={t['function']['name'] for t in SKILL_TOOLS}; \ + assert names=={'navigate','click','type','select','scroll','extract','done'}, names; \ + assert SKILL_TOOLS is not CHAT_TOOLS and names!={t['function']['name'] for t in CHAT_TOOLS}; \ + assert isinstance(SKILL_TOOLS_TEXT,str) and 'tool_use' in SKILL_TOOLS_TEXT; print('OK')" + ``` +3. **One action/step, both tool-call shapes normalized.** The loop emits **one** action per step and parses **both** OpenAI `tool_calls` and Qwen XML `` into a single normalized `(verb, args)`, feeding the action result + next snapshot back into the next step. Asserted in `test_loop.py` with two scripted models (one returning OpenAI-shaped tool calls, one returning XML content) producing the **same** executed action sequence on the same fake page. +4. **Termination: `done` (validated) and `max_steps` cap.** The loop terminates on `done{}` — and the claimed done is validated against `terminal_conditions` / `false_terminal_states` (a done that trips a `false_terminal_states` phrase is **rejected** and the loop continues, not silently accepted) — **or** on the configurable `max_steps` cap (default `20`, documented as a module constant). It returns an **ordered** list of step records + the accumulated extract records. Asserted in `test_loop.py`: (a) a happy-path script ending in a clean `done` → `outcome` reflects success, steps ordered; (b) a script whose `done` matches a `false_terminal_states` entry → `terminal_check == "rejected"` and the loop does not stop on it; (c) a script that never emits `done` with `max_steps=3` → loop stops after exactly 3 steps with `outcome == "capped"`. +5. **End-to-end test passes under `not live`.** + ```bash + pytest tests/skills/test_loop.py -m "not live" -p no:cacheprovider --no-cov -q + ``` + passes, using a **stubbed model** (scripted tool calls) and a **fake `SkillPage`** (no Playwright, no real Chrome — this issue's test must not require either). The test asserts: step ordering, done-validation (accept + reject paths), and cap behavior. Extract actions in the script accumulate into the returned `extracts` list in order. (`--no-cov` is only to run this file in isolation; the **full** `pytest -m "not live"` run must still meet `--cov-fail-under=80`, so ensure `loop.py`/`prompt.py` are covered.) + +### Verifying against a real local Chrome (optional, out of this issue's required tests) + +This issue's loop is provider/page-agnostic and its required tests use stubs, so a real browser is **not** needed to land it. The real-Chrome path is exercised end-to-end in issue **07** (behind the `live` marker, `playwright install chromium` per `TESTING.md`). If you want a manual smoke before 05/07 land: with issues 01+02 present and a Chrome reachable via `browser_pool`, construct a real `SkillPage` over a `connect_over_cdp` endpoint, pass a `model_call` bound to a local `qwen3:4b` (Ollama, via `provider_from_model`-shaped config), and run `run_skill_loop(page=..., model_call=..., skill_md=, task="...", max_steps=5)` against a benign read-only page; confirm the loop perceives, the model emits single verbs, and it ends on `done`/cap. Keep it read-only (no submit/pay/post/delete) since the **risk gate does not exist yet** in this issue — every action auto-runs. + +## Out of scope / non-goals + +- **No risk/confirm gate** (issue 04): every action auto-runs here; no `awaiting_confirm`, no `Proposal`, no `auto_confirm`, no high-risk classifier. +- **No run/event/spine integration** (issue 05): no `events.emit`, no `SkillChannel.collect`, no `parameters["run_id"]`/`chrome_endpoint`, no `ChannelResult` assembly, no provider **resolution** (the loop takes an already-bound `model_call`). **Do not** change `AbstractChannel.collect`'s signature. +- **No `journey_trace_v1` / re-distill** (issue 06): the loop returns step records the trace builder will consume; it does not build the trace or call `distill_trace`. +- **No browser/CDP/perception/executor implementation** (issues 01/02): consumed via interfaces only. +- **Do not reuse `chat.py`'s `TOOLS`/`WRITE_TOOLS`** as the verb set — define the skill's own. +- v2 (explicitly not here): cross-process pause/resume, auto-triggered re-distill, NAT/edge execution, vision/raw-DOM/screenshot perception, `evaluate(js)` escape hatch. diff --git a/docs/skills-issues/04-risk-tiered-confirm-gate-auto-confirm-awaiting-con.md b/docs/skills-issues/04-risk-tiered-confirm-gate-auto-confirm-awaiting-con.md new file mode 100644 index 00000000..beb0ac3f --- /dev/null +++ b/docs/skills-issues/04-risk-tiered-confirm-gate-auto-confirm-awaiting-con.md @@ -0,0 +1,345 @@ +# 04 Risk-tiered confirm gate + auto_confirm + awaiting_confirm run status + migration + +> Self-contained build unit. Authority for the design is **ADR-0003** +> (`docs/adr/0003-skill-execute-loop-architecture.md`) and the PRD +> (`docs/skills-execute-loop-PRD.md` §4 D4/D5/D8, §5 data delta, §6 integration +> table). The 8 ADR decisions are **fixed** — do not re-litigate them. Read those +> two docs first; everything below is pinned to this codebase's real symbols. + +## Context + +The skill execute loop lets a cheap text model drive a real Chrome page one +action per step. **ADR-0003 D4 (Guardrail — risk-tiered confirm)** says reads / +navigation / scroll / extract auto-run, but an action that matches the skill's +`red_lines` **or** a high-risk verb pattern (`submit | pay | post | delete`) +requires confirm — "写前确认是硬底线". A source may opt a trusted skill into +unattended running with `channel_config.auto_confirm = true` (default **off**). +This issue builds the **pure risk classifier** plus the **gate** that sits +between the loop's chosen action and `execute_action`, and the **`awaiting_confirm` +run status** plumbing (**ADR-0003 D5 / D8**, PRD §5): in headless mode, hitting a +confirm-required action stops the loop and surfaces `awaiting_confirm` up to the +runner via `ChannelResult.metadata` instead of completing. Interactive +synchronous confirm (the dock round-trip) is **out** — that is issues 05/06; here +the testable behavior is the **headless abort**. Ships an anchor Alembic migration +that chains the current head so this feature owns its status string. + +This is the safety spine of the loop. The dangerous failure mode is a **false +negative** (a write mis-classified as auto-run = a silent submit/pay/post), so the +classifier defaults to confirm on ambiguity and is unit-tested hard. + +## Scope + +**In scope** +- `backend/skills/risk.py` — pure `classify_action(action, element, skill)` → + tier + `needs_confirm`. Conservative (ambiguous ⇒ `needs_confirm=True`). + `red_lines` authoritative over the generic verb pattern. +- The **confirm gate** wired into the loop (`backend/skills/loop.py`): between the + model's chosen action and `execute_action`, call `classify_action`; honor + `auto_confirm`; on a blocked action in headless mode, stop and set the + `awaiting_confirm` metadata contract. +- `auto_confirm` read from `config` (`DataSource.channel_config["auto_confirm"]`, + default `False`) — no schema change. +- The **`awaiting_confirm` propagation contract** via `ChannelResult.metadata` + (`metadata[AWAITING_CONFIRM] = True` + the proposed action), so it can ride the + existing `PipelineResult.metadata` path up to the runner. Centralize the status + string as a **constant**, not inlined. +- `backend/migrations/versions/n4i5j6k7l8m9_add_awaiting_confirm_run_status.py` + with `down_revision = 'm3h4i5j6k7l8'` — the anchor migration for this feature. + `upgrade()` may be a documented **no-op** (`TaskRun.status` is free-text + `String(50)`; PRD §5). +- `tests/skills/test_risk.py` — covers auto-run tiers, generic high-risk verbs, + `red_line` precedence, and `auto_confirm` bypass; passes under `-m "not live"`. + +**Out of scope** +- Synchronous **dock confirm UI / endpoint round-trip** (interactive resume is the + issue 05/06 surface). Here the only testable confirm behavior is the **headless + abort**. +- The **Phase-4 status write** in `runner.py` and the `run_id`/endpoint injection + in `pipeline.py` — that wiring is **issue 05**. This issue only defines the + *contract* (the metadata key + constant) and proves it is observable at the + `ChannelResult` boundary. +- **Cross-process pause/resume** of a paused headless run (v2). +- The loop itself, perception, action executor, provider resolution (issues + 01–03). This issue assumes `backend/skills/loop.py` exists from issue 03 and + adds the gate to it. + +## Depends on + +- **03** — Cheap-model step loop + 9-element prompt + tool-calling harness + (`backend/skills/loop.py`, `backend/skills/prompt.py`). The gate sits inside the + loop's per-step path (model picks an action → **gate** → `execute_action`). + Transitively 01 + 02 (page wrapper, action executor) ship the verb set the + classifier reasons over. + +## Files + +| File | Create/Edit | Purpose (one line) | +|---|---|---| +| `backend/skills/risk.py` | **create** | Pure `classify_action(...)`, the `RiskTier` enum, `HIGH_RISK_VERBS`, and the `AWAITING_CONFIRM` status constant. No DB / no I/O. | +| `backend/skills/loop.py` | **edit** (from issue 03) | Insert the confirm gate between chosen action and `execute_action`; read `auto_confirm` from `config`; on a blocked headless action, stop and set the awaiting-confirm metadata. | +| `backend/migrations/versions/n4i5j6k7l8m9_add_awaiting_confirm_run_status.py` | **create** | Anchor Alembic migration; `down_revision='m3h4i5j6k7l8'`; `upgrade()` documented no-op. | +| `tests/skills/test_risk.py` | **create** | Unit tests for the classifier + gate decision; runs under `-m "not live"`. | + +> If issue 03 has **not** landed yet, still create `risk.py`, the migration, and +> `test_risk.py` (all standalone), and add a `TODO(05)` marker where the gate call +> will be inserted in `loop.py` — the classifier and its tests must be green +> independently. `tests/skills/` does not exist yet; create it (with +> `tests/skills/__init__.py` if the suite uses package dirs). + +## Implementation notes + +### 1. `backend/skills/risk.py` — pure classifier + +The classifier reasons over **one action** (the verb set from ADR-0003 D3: +`navigate{url}`, `click{ref}`, `type{ref,text,submit?}`, `select{ref,value}`, +`scroll{dir}`, `extract{data}`, `done{status,note}`) plus the **resolved element** +it targets (the `{ref, role, name, value}` snapshot entry from the perception +layer — issue 01) plus the `Skill` (for `red_lines` from `Skill.elements`). + +```python +from dataclasses import dataclass +from enum import Enum + +# Centralized run-status string (PRD §7: status is free-text — typos won't be +# caught by the DB, so define it once). Imported by loop.py / runner (issue 05). +AWAITING_CONFIRM = "awaiting_confirm" + +# Generic high-risk verbs (ADR-0003 D4). Matched against the action verb AND the +# target element's name/role/value, case-insensitive, word-ish. +HIGH_RISK_VERBS = ("submit", "pay", "post", "delete") + +# Verbs that are inherently safe regardless of target (ADR-0003 D4: reads / +# navigation / scroll / extract auto-run). +AUTO_RUN_VERBS = ("navigate", "scroll", "extract", "done") + +class RiskTier(str, Enum): + AUTO = "auto" # read/navigate/scroll/extract — runs unattended + CONFIRM = "confirm" # write/high-risk — needs confirm + +@dataclass(frozen=True) +class RiskDecision: + tier: RiskTier + needs_confirm: bool + reason: str # why (for the event detail + tests) + matched_red_line: str | None = None + +def classify_action(action: dict, element: dict | None, skill) -> RiskDecision: + ... +``` + +Decision order (this order is the contract — tests assert it): + +1. **`red_lines` first and authoritative.** Read the skill's red lines + (`skill.elements.get("red_lines")` — a list of strings; also accept a plain + dict/`Skill`-like object so the classifier is testable without a DB row). If + the action (verb + target element name/role/value, lowercased) matches any red + line, return `tier=CONFIRM, needs_confirm=True, matched_red_line=`. + **This wins even when the verb would otherwise be auto-run** (e.g. an + `extract` named in `red_lines`). This is acceptance criterion 2 — red_lines + take precedence over the generic pattern. +2. **Generic high-risk pattern.** If the verb is `click`/`type`/`select` **and** + the verb token or the element name/role/value contains a `HIGH_RISK_VERBS` + token (`submit|pay|post|delete`) — e.g. a button named "Submit order", role + `button` with name "Delete", a `type{...,submit:true}` — return + `tier=CONFIRM, needs_confirm=True`. +3. **Auto-run tiers.** `navigate` / `scroll` / `extract` / `done`, and any + plain read-style `click`/`select` that matched nothing above → `tier=AUTO, + needs_confirm=False`. **Any read is auto.** +4. **Ambiguous default ⇒ confirm.** If the action verb is unrecognized, the + target element can't be resolved for a write-ish verb + (`click`/`type`/`select` with `element is None`), or matching is uncertain → + `tier=CONFIRM, needs_confirm=True, reason="ambiguous-default-confirm"`. PRD §7: + "Keep the classifier conservative (default to confirm on ambiguity)." + +Pure function: **no DB session, no Playwright, no events** — it takes plain data +and returns a `RiskDecision`. This is what makes acceptance criterion 6 (unit +tests, `-m "not live"`, no browser) trivial. + +`type{...,submit:true}` is a write regardless of the element name — treat the +`submit` flag as a high-risk signal in step 2. + +### 2. Gate wiring in `backend/skills/loop.py` + +The loop (issue 03) runs: perceive → cheap model emits one action → **gate** → +`execute_action` → emit event → repeat until `done`/cap. Insert the gate between +the chosen action and `execute_action`: + +```python +from backend.skills.risk import classify_action, RiskTier, AWAITING_CONFIRM + +decision = classify_action(action, target_element, skill) +if decision.needs_confirm and not auto_confirm: + # headless v1: abort cleanly (interactive resume = issue 05/06) + await events.emit( + run_id, AWAITING_CONFIRM, + f"awaiting confirm: {action.get('verb')} ({decision.reason})", + level="warning", + detail={"action": action, "decision": decision.reason, + "matched_red_line": decision.matched_red_line}, + ) + return ChannelResult.ok( + items, # whatever extracted before the gate still flows + channel="skill", + executed=True, + **{AWAITING_CONFIRM: True}, # metadata key == the status constant + proposed_action=action, # what the operator must confirm + ) +# auto-run, or auto_confirm bypass: +result = await execute_action(action, ...) +``` + +Honor these existing seams (do **not** change their signatures): + +- `auto_confirm` comes from `config` — i.e. `DataSource.channel_config["auto_confirm"]` + (default `False`). `SkillChannel.collect(config, parameters)` already reads it + (`backend/channels/skill_channel.py` line 62: + `auto_confirm = bool(config.get("auto_confirm", False))`). Thread that value into + the loop; `SkillChannel.validate_config` already tolerates the key. +- **Do NOT change `AbstractChannel.collect(config, parameters)`** + (`backend/channels/base.py`). The metadata rides `ChannelResult.metadata` — that + is the contract change, and it's additive (`ChannelResult.ok(items, **metadata)` + already exists, line 20). +- Per-step events use the module-level `backend/pipeline/events.py::emit(run_id, + step, message, level, detail, elapsed_ms)` (best-effort, never raises). `step` is + free-text `String(50)`; use `AWAITING_CONFIRM` as the step value so it's the + centralized constant, not an inlined literal. +- The proposal payload shape should mirror chat's + `backend/api/v1/chat.py::Proposal{tool,args,summary,diff}` and the `WRITE_TOOLS` + set / `_is_xml_tool_model` / `_parse_tool_use` pattern (line 144, 460, 465) so + issues 05/06 can reuse the dock's diff-card → `/chat/confirm` flow. v1 only needs + the *action* in metadata; full `Proposal` rendering is 06. + +### 3. `awaiting_confirm` propagation contract (no runner change here) + +The runner's Phase 4 (`backend/pipeline/runner.py`, lines ~158–184) currently +forces `run.status` to `"completed"`/`"failed"`. The **status write** that reads +the metadata and sets `run.status = AWAITING_CONFIRM` is **issue 05's** edit. +This issue's job is to make the signal **exist and propagate to the +`ChannelResult` boundary**: + +- The loop sets `ChannelResult.metadata[AWAITING_CONFIRM] = True` + `proposed_action`. +- `backend/pipeline/pipeline.py::run_pipeline` already passes channel metadata + straight through on the success path: + `return PipelineResult(..., metadata=channel_result.metadata)` (line 253). So the + flag reaches `runner` Phase 4 via `pipeline_result.metadata` for free — **no + pipeline.py change needed for the carry**. +- Document in this issue's note (and a `TODO(05)`) that Phase 4 must read + `pipeline_result.metadata.get(AWAITING_CONFIRM)` and set + `run.status = AWAITING_CONFIRM` instead of `"completed"`. Wiring + the run-list / + dock legend is issue 05. + +### 4. Anchor migration + +`backend/migrations/versions/n4i5j6k7l8m9_add_awaiting_confirm_run_status.py`: + +```python +"""add awaiting_confirm run status (anchor for the skill confirm gate) + +Revision ID: n4i5j6k7l8m9 +Revises: m3h4i5j6k7l8 +Create Date: 2026-06-30 +""" +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + +revision = "n4i5j6k7l8m9" +down_revision = "m3h4i5j6k7l8" # current head (m3h4i5j6k7l8_add_skills) +branch_labels = None +depends_on = None + +def upgrade() -> None: + # No-op: TaskRun.status is free-text String(50); the new 'awaiting_confirm' + # value needs no DDL. This migration is the anchor so the skill confirm-gate + # feature owns the Alembic head and the status string is documented in the + # chain. (PRD §5.) + pass + +def downgrade() -> None: + pass +``` + +`m3h4i5j6k7l8` is verified to be the current single head with **no child +revision yet** (versions dir ends at `m3h4i5j6k7l8_add_skills.py`). Keep the +`abc…l8 / l8…m9` style ID so it sorts after the existing chain. + +## Acceptance criteria + +Run from `D:/projects/opencli-admin`. The suite default is `--cov-fail-under=80`; +these tests are non-live, so `-m "not live"` keeps them browser-free. + +1. **Auto-run tiers + reads.** + `classify_action` returns `needs_confirm=False` (tier `AUTO`) for `navigate`, + `scroll`, `extract`, and any read-style `click`/`select` that matches no + high-risk token and no red line. Verify: + ```bash + uv run pytest tests/skills/test_risk.py -m "not live" -q + ``` +2. **Generic high-risk verbs ⇒ confirm.** An action whose verb token **or** target + element name/role/value matches `submit|pay|post|delete` (e.g. `click` on a + button named "Submit", `delete`-named control, or `type{...,submit:true}`) + returns `needs_confirm=True`. (test in the same file) +3. **`red_line` precedence.** An action that matches a skill `red_line` is + `needs_confirm=True` **even when the verb would otherwise auto-run** (e.g. an + `extract`/`navigate` named in `red_lines`); `RiskDecision.matched_red_line` is + set. Asserts red_lines beat the generic pattern. +4. **Ambiguous ⇒ confirm.** Unrecognized verb, or a write-ish verb + (`click`/`type`/`select`) with `element is None`, returns `needs_confirm=True` + with `reason="ambiguous-default-confirm"`. +5. **`auto_confirm` bypass.** With `auto_confirm=True` a `needs_confirm` action is + allowed to run (gate does not abort); with `auto_confirm` absent/`False` it is + not auto-run. Tested at the gate-decision level (a tiny helper that takes + `decision + auto_confirm` and returns run/abort), so no browser is needed. +6. **Headless abort surfaces the contract.** A loop run in headless mode (no + `auto_confirm`) that reaches a `needs_confirm` action stops and the returned + `ChannelResult.metadata["awaiting_confirm"] is True` with the proposed action + present (`metadata["proposed_action"]`); a skill with **no** high-risk action + runs fully through (`metadata` has no `awaiting_confirm` / it is falsy). + - If issue 03's loop is landed, assert this against the loop with a fake action + stream / monkeypatched `execute_action` (still `-m "not live"`). + - If the loop is not yet landed, assert the **gate helper** returns abort vs run + for the same inputs and leave the loop assertion as a `TODO(05)` xfail. The + classifier + gate-decision tests must be green regardless. +7. **Status string is a constant.** `grep` proves `awaiting_confirm` is not + inlined where it's used as a status/step: + ```bash + uv run python -c "from backend.skills.risk import AWAITING_CONFIRM; print(AWAITING_CONFIRM)" + # -> awaiting_confirm + ``` + `loop.py` (and later `runner.py`) import `AWAITING_CONFIRM`, not the literal. +8. **Migration chains cleanly.** The file exists with + `down_revision = "m3h4i5j6k7l8"`, and the Alembic chain is single-headed and + round-trips: + ```bash + uv run alembic heads # -> n4i5j6k7l8m9 (single head) + uv run alembic upgrade head # clean (upgrade is a no-op) + uv run alembic downgrade -1 # clean + uv run alembic upgrade head + ``` + +### Verifying against a real local Chrome (optional, issue 05/07 territory) + +The classifier and gate are **pure / unit-level** — no Chrome needed, which is the +point of the design. The end-to-end "headless run hits a high-risk action over a +real CDP page and the run lands at `awaiting_confirm`" check belongs to the live +e2e (issue 07, `live` marker) and the runner status write (issue 05). If you want +a smoke check here: construct a `Skill` with a `red_lines` entry, feed the gate a +synthetic `click` action on an element whose name matches it, and assert the gate +returns the `awaiting_confirm` `ChannelResult` — no browser required. + +## Out of scope / non-goals + +- Interactive **synchronous confirm** (dock diff-card → `/chat/confirm` → + resume). v1 headless behavior is **abort at `awaiting_confirm`**; the + interactive round-trip + `Proposal` rendering is issues **05/06**. +- The Phase-4 `run.status = awaiting_confirm` write in `runner.py`, the + `run_id`/`chrome_endpoint` injection in `pipeline.py`, and run-list / dock + status-legend surfacing — **issue 05**. +- **Cross-process pause/resume** of a paused run (v2). A headless run that hits a + confirm-required action ends; operators re-run interactively or set + `auto_confirm`. +- **Auto-triggered re-distill** after N failures (v2). +- Any change to `AbstractChannel.collect(config, parameters)` (forbidden by + ADR-0003 D5) — the only contract change is additive `ChannelResult.metadata`. +- Schema changes to `DataSource.channel_config` (`auto_confirm` is JSON, no + migration) or to `TaskRun.status` (free-text `String(50)`; the migration is a + documented no-op anchor). diff --git a/docs/skills-issues/05-run-integration-wire-skillchannel-collect-into-the.md b/docs/skills-issues/05-run-integration-wire-skillchannel-collect-into-the.md new file mode 100644 index 00000000..3275b4f6 --- /dev/null +++ b/docs/skills-issues/05-run-integration-wire-skillchannel-collect-into-the.md @@ -0,0 +1,143 @@ +# 05 Run integration: wire SkillChannel.collect into the task/run/pipeline spine + +> Self-contained issue. Source of truth: `docs/adr/0003-skill-execute-loop-architecture.md` (ADR-0003) and `docs/skills-execute-loop-PRD.md` (§4 D5, §5, §6, §8). Repo root: `D:/projects/opencli-admin`. Read those two docs + the files listed below before starting; everything you need to implement this issue is named here. + +## Context + +This is the **execute** leg's "make it actually run inside the existing system" issue. Issues 03 (cheap-model step loop + 9-element prompt + tool-calling harness) and 04 (risk-tiered confirm gate + `auto_confirm` + `awaiting_confirm` status) build the loop and the gate as standalone backend/skills modules. This issue replaces the stub body of `backend/channels/skill_channel.py::SkillChannel.collect` with a call into that loop, and threads it through the existing **task → run → pipeline → events → record** spine **without changing any channel contract**. This realizes ADR-0003 **D5 (Run integration: stay in the spine, no `collect()` contract change)** and the `awaiting_confirm` half of **D8 (v1 interactive-first; headless aborts cleanly on a confirm-required action)**. After this issue, a `skill` `DataSource` runs end-to-end through the same `run_pipeline` that `opencli`/`rss` use: per-step `TaskRunEvent`s show up in the run-events UI, `extract` records land in the store via the normal normalize/dedup/AI/notify path, and a paused loop drives the run to `awaiting_confirm` instead of a false `completed`/`failed`. + +The seam is already open (PRD §2): `SkillChannel.collect(config, parameters)` is invoked by `collector.collect`, already acquires a CDP endpoint from `browser_pool`, already resolves the SKILL.md (inline `config["skill_md"]`) and the cheap-executor `provider`. What is stubbed is everything between "I have a CDP endpoint" and "here are the extracted records": today it returns one `proposed_step` dict with `executed=False, skeleton=True` and stops at the gate. This issue makes it drive the real loop. + +## Scope + +**In scope** +- `backend/channels/skill_channel.py` — replace the skeleton body of `collect` with the real perceive→gate→act loop: load SKILL.md, resolve the cheap-executor provider, acquire the browser (already wired), run the loop (issues 03/04), emit per-step `TaskRunEvent`s via `events.emit(run_id, ...)`, return `extract` records as `ChannelResult.items`, and propagate `awaiting_confirm` in `ChannelResult.metadata`. +- `backend/pipeline/pipeline.py::run_pipeline` — add a `channel_type == "skill"` branch in the pre-step + collect-event block that injects `params["run_id"] = run_id` (and `params["chrome_endpoint"]` from a browser binding when one exists), mirroring the existing `opencli` special-case; build a `skill`-flavored collect-event `detail`; and propagate `channel_result.metadata["awaiting_confirm"]` into the returned `PipelineResult.metadata`. +- `backend/pipeline/runner.py::run_collection_pipeline` Phase 4 — when the pipeline reports a paused outcome (`pipeline_result.metadata.get("awaiting_confirm")`), set `run.status = "awaiting_confirm"` (and a matching `task.status`) instead of forcing `completed`/`failed`. +- `tests/skills/test_skill_channel.py` — new test module (creating it creates the `tests/skills/` dir) covering the wiring: events emitted, items stored, `awaiting_confirm` status. Must run under `-m "not live"`. + +**Out of scope (deferred to other issues / v2)** +- **Changing `AbstractChannel.collect(config, parameters)` signature — forbidden** (ADR-0003 D5). The loop must receive `run_id` and `chrome_endpoint` through `parameters`, not via a new arg. +- **The loop internals and the risk gate themselves** — issues 03 and 04. This issue *calls* them; it does not reimplement the perceive/act/gate logic, the verb schema, the tool-calling harness, or the risk classifier. +- **`journey_trace_v1` trace assembly + re-distill / correction path + dock "重蒸技能"** — issue 06. This issue may pass through a `trace` value if the loop returns one, but does not build the trace or wire re-distill. +- **The human *record* leg** ("录这站", the record-leg producer) — separate TODO (PRD §1 non-goals). +- **`skill_id` / `(domain, capability)` → DB resolution** — may stay deferred per the existing skeleton TODO (`_resolve_skill_md`) as long as inline `config["skill_md"]` works. Do not block this issue on a `SkillService`. +- **Cross-process pause/resume** of a headless run that hit `awaiting_confirm` — v2. v1 simply stops at that status. +- **The `awaiting_confirm` anchor migration + run-list/run-detail API + dock legend surfacing** — owned by issue 04 (PRD §5). This issue only needs Phase 4 to *set* the status; since `TaskRun.status` is free-text `String(50)`, storing the value needs no schema change. + +## Depends on + +- **03** — Cheap-model step loop + 9-element prompt + tool-calling harness (`backend/skills/loop.py`, `backend/skills/prompt.py`). Provides the callable the channel drives. +- **04** — Risk-tiered confirm gate + `auto_confirm` + `awaiting_confirm` status + anchor migration (`backend/skills/risk.py`, migration, `backend/channels/base.py` metadata usage). Provides the gate the loop consults and the `awaiting_confirm` status anchor. + +If 03/04 land a concrete entrypoint name different from what this file assumes, adapt to the real symbol — the contract this issue depends on is: *something callable that, given a connected page + provider + SKILL.md elements + an `emit` callback + an `auto_confirm` flag, runs the loop and yields (extract records, awaiting_confirm flag, optional trace)*. + +## Files + +| File | Create / Edit | Purpose (one line) | +|---|---|---| +| `D:/projects/opencli-admin/backend/channels/skill_channel.py` | Edit | Replace the skeleton `collect` body with the real loop wiring: read `run_id`/`chrome_endpoint` from `parameters`, drive the loop, emit per-step events, return `extract` records as `items`, propagate `awaiting_confirm` in `metadata`. | +| `D:/projects/opencli-admin/backend/pipeline/pipeline.py` | Edit | Add `channel_type == "skill"` branch to inject `run_id` (+ `chrome_endpoint` from a binding) into `params` and build the collect-event `detail`; propagate `metadata["awaiting_confirm"]` into `PipelineResult.metadata`. | +| `D:/projects/opencli-admin/backend/pipeline/runner.py` | Edit | Phase 4: set `run.status="awaiting_confirm"` (+ `task.status`) when `pipeline_result.metadata["awaiting_confirm"]` is truthy, instead of `completed`/`failed`. | +| `D:/projects/opencli-admin/tests/skills/test_skill_channel.py` | Create | Drive `run_pipeline` / `run_collection_pipeline` for a `skill` source with a stubbed model + fake page; assert events emitted, items stored, and the `awaiting_confirm` path sets the run status. Runs under `-m "not live"`. | + +## Implementation notes + +Concrete to this codebase's symbols. Honor the fixed decisions: **do not change `AbstractChannel.collect`'s signature**, and reuse the spine — do not add a parallel runner for skills. + +### 1. `run_pipeline` — inject `run_id` + endpoint for `skill` (mirror the `opencli` case) + +In `backend/pipeline/pipeline.py::run_pipeline`: + +- **Pre-step endpoint binding.** The existing block (around lines 45–55) only runs for `source.channel_type == "opencli"`: + ```python + if source.channel_type == "opencli" and not params.get("chrome_endpoint"): + site = source.channel_config.get("site", "") + ... + binding = await browser_service.get_binding_by_site(session, site) + if binding: + params = {**params, "chrome_endpoint": binding.browser_endpoint} + ``` + Add an analogous `skill` branch. A skill source's site key may live under a different config key than `opencli`'s `"site"` (e.g. `channel_config.get("site")` or a skill-specific binding); resolve `chrome_endpoint` from `browser_service.get_binding_by_site(session, site)` when a site is present, and otherwise leave it unset so `browser_pool.acquire(endpoint=None)` picks a default. Keep this best-effort (a missing binding is not an error — the pool can still acquire). +- **Inject `run_id` into `params`.** Critical: `SkillChannel.collect` only receives `(config, parameters)`. The loop needs `run_id` to call `events.emit(run_id, ...)`. Add, in the `if run_id:` collect block (around line 62), a `skill` branch that does `params = {**params, "run_id": run_id}` **before** `collector.collect(source, params)` is called (line 93). Do this for `skill` specifically (don't blanket-inject for all channels — other channels don't expect it, and the `opencli` detail-builder explicitly strips `chrome_endpoint` from params, so keep behavior scoped). +- **Collect-event `detail`.** In the same `if run_id:` block, give `skill` a flavored `collect_detail` (e.g. include `channel_type`, the skill char count if cheaply available, and the resolved `chrome_endpoint` presence) similar to how `opencli` builds a `command` string. Keep it small; this is just for the run-events UI. +- **Propagate `awaiting_confirm` up.** `run_pipeline` already returns `metadata=channel_result.metadata` in the success `PipelineResult` (line 253). That means if `SkillChannel` puts `awaiting_confirm` in `ChannelResult.metadata`, it already flows to `PipelineResult.metadata` on the success path — verify this and do not drop it. (A paused run is still a *successful pipeline execution* — collect/normalize/store all ran; it just paused. Return `success=True` with `metadata["awaiting_confirm"]=True`.) + +### 2. `SkillChannel.collect` — drive the loop (replace the skeleton) + +In `backend/channels/skill_channel.py`, keep `validate_config` and `_resolve_skill_md` as-is. Replace the body after the `async with pool.acquire(...) as cdp_endpoint:` line: + +- Read `run_id = parameters.get("run_id")`. Build a tiny per-step emit helper that calls the module-level `from backend.pipeline import events` → `await events.emit(run_id, step, message, level=..., detail=..., elapsed_ms=...)` — but **no-op when `run_id` is None** (so a direct unit-test call without the pipeline still works). `events.emit` is best-effort and never raises. +- Connect to the page over CDP (Playwright wrapper from issue 01, `backend/skills/page.py`) using `cdp_endpoint`. (Issues 01–03 own the page/loop; from this file you just hand the connected page + provider + SKILL.md + emit + `auto_confirm` to the loop entrypoint.) +- Resolve the SKILL.md 9 elements: for v1, parse from inline `skill_md` (already loaded) / `config` per issue 03's prompt builder. `provider = config.get("provider", {})` is the cheap-executor config (same shape as `backend/skills/distill.py` provider). `auto_confirm = bool(config.get("auto_confirm", False))`. +- Run the loop (issue 03 entrypoint). The loop must emit per-step events through the emit helper. **Step names** (free-text `TaskRunEvent.step`, `String(50)`) — use exactly these so the UI/tests can key on them: `skill_perceive`, `skill_step`, `skill_extract`, `awaiting_confirm`, `skill_done` (PRD §6 also lists `self_eval`, which belongs to issue 06). +- Collect `extract{data}` results into an `items: list[dict]`. Return: + ```python + return ChannelResult.ok( + items, + channel="skill", + chrome_mode=mode, + executed=True, + awaiting_confirm=, # True iff the loop paused at the gate + # trace=, # optional pass-through; assembly is issue 06 + ) + ``` + `ChannelResult.ok(items, **metadata)` (see `backend/channels/base.py`) folds every keyword into `.metadata`, so `awaiting_confirm` lands in `metadata` automatically. Keep the existing `try/except` that maps a browser/exec failure to `ChannelResult.fail(...)`. +- The current stub returns `executed=False, skeleton=True` — remove those. + +### 3. `runner.py` Phase 4 — honor the paused status + +In `backend/pipeline/runner.py::run_collection_pipeline`, Phase 4 (around lines 158–184) currently branches only on `pipeline_result.success`: success → `completed`, else → `failed`. Insert a paused branch **before** the success/failure decision: + +```python +if pipeline_result.metadata.get("awaiting_confirm"): + if task: + task.status = "awaiting_confirm" + task.error_message = None + if run: + run.status = "awaiting_confirm" +elif pipeline_result.success: + ... existing completed branch ... +else: + ... existing failed branch ... +``` + +`run.finished_at`, `run.duration_ms`, `run.records_collected` are still set (a paused run did collect/store whatever it got before pausing). `TaskRun.status` and `CollectionTask.status` are free-text `String(50)` (see `backend/models/task.py`), so no migration is needed here to store `"awaiting_confirm"` — the anchor migration is issue 04's. Leave the return dict shape unchanged (callers read `success`/`run_id`/`stored`). + +### 4. The spine you are reusing (do not duplicate) + +- `backend/pipeline/collector.py::collect` already does `get_channel("skill").collect(source.channel_config, parameters)` — **no change** (it's dispatch-only). `skill` is already registered (`backend/channels/registry.py` imports `skill_channel`). +- `backend/pipeline/events.py::emit(run_id, step, message, level="info", detail=None, elapsed_ms=None)` writes one `TaskRunEvent` row, best-effort. Reuse verbatim; do not add a new event writer. +- `extract` records returned in `ChannelResult.items` go through `normalizer.normalize_items(items, source.id)` then `storer.store_records(session, task_id, source.id, triples, channel_type="skill")` — the **same** path every channel uses (pipeline.py lines 130 & 143). Make `extract` payloads dict-shaped records (e.g. include a `url`/`title`/`content`-ish key the normalizer/dedup expects) so they store + dedup like any other record. No change to normalizer/storer. +- `browser_pool.get_pool().acquire(endpoint=...)` and `pool.get_mode(ep)` are already wired in the skeleton — keep them. + +## Acceptance criteria + +Falsifiable. Run from repo root `D:/projects/opencli-admin`. The suite default is `addopts = --cov=backend --cov-report=term-missing --cov-fail-under=80` with `asyncio_mode = "auto"`; the `live` marker is deselected with `-m "not live"`. + +1. **Contract unchanged.** `AbstractChannel.collect(config, parameters)` signature is byte-for-byte unchanged in `backend/channels/base.py` (no new positional/keyword arg). `SkillChannel.collect` drives the perceive→gate→act loop and, on a clean run, returns `ChannelResult.ok(items=, channel="skill", executed=True, awaiting_confirm=False)`. Verify: `git diff backend/channels/base.py` touches nothing in the `collect` signature; `grep -n "skeleton" backend/channels/skill_channel.py` returns nothing. + +2. **`run_id` + endpoint injection.** `run_pipeline` has a `channel_type == "skill"` branch that sets `params["run_id"] = run_id` (and `params["chrome_endpoint"]` when a binding exists) **before** `collector.collect` is dispatched; `SkillChannel.collect` reads `run_id` from `parameters` and calls `events.emit(run_id, step, ...)` for each step. Observable: a skill run produces `TaskRunEvent` rows whose `step` ∈ {`skill_perceive`, `skill_step`, `skill_extract`, `skill_done`} (and `awaiting_confirm` on the paused path) for that `run_id`. + +3. **Extracts flow through the normal store path.** `extract` records returned in `ChannelResult.items` pass through `normalizer.normalize_items` and `storer.store_records` unchanged: a skill run whose loop emits N `extract` records produces stored `CollectedRecord`s (and `PipelineResult.stored == `), with no skill-specific store branch added. + +4. **Paused run → `awaiting_confirm` (not completed/failed).** When the loop pauses at the gate, `ChannelResult.metadata["awaiting_confirm"]` is `True`, it propagates to `PipelineResult.metadata["awaiting_confirm"]`, and `runner.run_collection_pipeline` Phase 4 sets `run.status == "awaiting_confirm"` (and `task.status == "awaiting_confirm"`) — **not** `completed`/`failed`. A clean run (no pause) still sets `run.status == "completed"` exactly as before (existing `test_run_pipeline_*` tests stay green). + +5. **Integration test under `-m "not live"`.** `tests/skills/test_skill_channel.py` drives `run_collection_pipeline` (or `run_pipeline`) for a `skill` `DataSource` with **(a)** a stubbed cheap model (patch the loop's model call / tool-calling harness so it returns a scripted action sequence — e.g. `extract` then `done`) and **(b)** a fake page (patch the Playwright page wrapper from issue 01 so no real Chrome/CDP is needed), and asserts: (i) `TaskRunEvent`s were emitted for the run (query rows by `run_id`, assert the expected `step` values appear); (ii) items reached the store (assert `stored`/`CollectedRecord` count); (iii) the `awaiting_confirm` script drives `run.status == "awaiting_confirm"`. Command: `python -m pytest tests/skills/test_skill_channel.py -m "not live" -q` passes. Follow the patch style in `tests/unit/pipeline/test_pipeline.py` (patch `backend.pipeline.collector.collect`, `backend.pipeline.storer.store_records`, `backend.database.AsyncSessionLocal`) and the SQLite in-memory `db_session` fixture in `tests/conftest.py`. + +6. **No regressions.** `python -m pytest tests/unit/pipeline -m "not live" -q` stays green (the `opencli` auto-binding path and all `run_pipeline` success/failure/AI/notify tests are unaffected), and the full `python -m pytest -m "not live"` suite still meets `--cov-fail-under=80`. + +### Verifying against a real local Chrome (optional, behind `live`) + +This issue's required tests are headless (stub model + fake page). To smoke-test the wiring against a real browser without writing the full e2e (issue 07 owns that): with a Chrome reachable via `browser_pool` (a `live`-marked or manual run), create a `skill` `DataSource` with an inline `channel_config["skill_md"]` for a read-only task (only auto-run verbs: `navigate`/`extract`/`done`), trigger it through `run_collection_pipeline`, and confirm in the run-events UI (`/labs/topology`) that `skill_perceive`/`skill_step`/`skill_extract`/`skill_done` events appear and the run ends `completed` with stored records. A skill containing a high-risk verb (submit/pay/post/delete) with `auto_confirm` unset must end the run at `awaiting_confirm`. Gate any such test behind the `live` marker so the default suite needs no browser. + +## Out of scope / non-goals + +- Changing `AbstractChannel.collect(config, parameters)` — **forbidden** (ADR-0003 D5). `run_id`/`chrome_endpoint` travel via `parameters`. +- Implementing the loop, prompt builder, tool-calling harness, verb schema, or risk classifier — issues 03/04. This issue only *calls* them. +- `journey_trace_v1` assembly, re-distill / correction service+endpoint, dock "重蒸技能" button — issue 06. +- The `awaiting_confirm` anchor migration + run-list/run-detail API filter + dock/run-UI status legend — issue 04 (and its UI follow-up). This issue only needs Phase 4 to *set* the status. +- The human *record* leg ("录这站") / record-leg producer — separate TODO. +- `skill_id` / `(domain, capability)` → DB resolution — may stay deferred (inline `skill_md` suffices for v1). +- Cross-process pause/resume of an `awaiting_confirm` run — v2. +- NAT/edge-node execution via `agent_server`; vision/raw-DOM/screenshot perception; `evaluate(js)` — rejected by ADR-0003 (D1/D2/D3). diff --git a/docs/skills-issues/06-journey-trace-v1-emission-re-distill-correction-pa.md b/docs/skills-issues/06-journey-trace-v1-emission-re-distill-correction-pa.md new file mode 100644 index 00000000..94f78c68 --- /dev/null +++ b/docs/skills-issues/06-journey-trace-v1-emission-re-distill-correction-pa.md @@ -0,0 +1,179 @@ +# 06 journey_trace_v1 emission + re-distill correction path + dock re-distill trigger + +> Self-contained build unit. Authority: `docs/adr/0003-skill-execute-loop-architecture.md` (decisions **D5**, **D6**, **D7**, **D8**) and `docs/skills-execute-loop-PRD.md` (§3 Flow A step 5–6 / Flow B step 4, §4 D7 + the `journey_trace_v1` shape block, §6 integration table rows "Extract → records" / "Re-distill" / "Confirm contract / dock"). Read those two before starting; everything you need to implement this issue is pinned below. + +## Context + +This issue closes the **self-eval / correction loop** (ADR-0003 **D7**) on top of the already-wired execute run from issue **05**. After 05, a `skill` `DataSource` runs end-to-end through the spine (`run_pipeline → collector.collect → SkillChannel.collect`), emits per-step `TaskRunEvent`s, and returns extracted items. What is still missing is the *feedback* leg: every run must assemble a `journey_trace_v1`-shaped trace from its step events + outcome so that the future human **record** leg and this **correct** leg feed the **same** distiller (`backend/skills/distill.py::distill_trace`, which already reads `trace["summary"]["domain"]`, `trace["label"]`, `trace["trace_id"]`); compute a self-eval (outcome vs the skill's `terminal_conditions`/`milestones`) appended to `skills.evidence`; and let a human re-distill a failing skill from the dock. Per **D7**, **correction is re-distillation, never a hand-patch** — re-distill bumps `version`, appends `evidence`, and replaces `skill_md`/`elements` from `to_skill_fields`. Per **D8**, v1 re-distill is **human-triggered only**; auto-trigger after N consecutive fails is v2. + +## Scope + +**In scope** +- `backend/skills/trace.py` — define the `journey_trace_v1` schema **once** in a shared module (so the record leg and the correct leg target the same shape), plus `assemble_trace(step_events, outcome, skill=...)` and `self_eval(outcome, skill)`. +- `backend/skills/correction.py` — `re_distill(...)` service: load `Skill` + failing trace(s) + current `skill_md` → call `distill_trace` → `version += 1`, append `evidence`, replace `skill_md`/`elements` from `to_skill_fields`. No hand-patching of fields. +- Wire trace assembly + self-eval into the execute run so **every** run produces a `journey_trace_v1` (surfaced on `ChannelResult.metadata["trace"]`) and a self-eval appended to `skills.evidence`. +- `backend/api/v1/skills.py` (NEW file) + register its router in `backend/api/v1/__init__.py` — an authenticated endpoint that triggers re-distill for a given `skill_id` + trace and returns the new version. +- `frontend/src/labs/topology/AgentDock.tsx` — a `重蒸技能` action that calls the new endpoint, reusing the existing proposal/confirm-style synchronous flow. +- `tests/skills/test_correction.py` (+ `tests/skills/__init__.py`) — re-distill unit tests with `distill_trace` stubbed, green under `-m "not live"`. + +**Out of scope (deferred)** +- **Auto-triggered re-distill after N consecutive fails** — explicitly **v2** (ADR-0003 D8; PRD §1 non-goals). Do not wire any automatic "N fails → re-distill" policy. v1 only *computes and logs* the self-eval signal. +- **The human record leg ("录这站")** that produces the *first* `journey_trace_v1` from a demonstration — separate TODO (PRD §1, §7). This issue only fixes the trace **shape** both legs share and produces it from execute runs. +- **Cross-process pause / resume** of an `awaiting_confirm` run — v2 (owned by issue 05 for the status itself; resume is v2). +- Everything else already owned by earlier issues: Playwright/page wrapper (01), action executor (02), step loop + prompt (03), risk gate + `awaiting_confirm` status + migration (04), spine wiring of `SkillChannel.collect` (05). + +## Depends on + +**05** — Run integration: `SkillChannel.collect` wired into the spine (`backend/channels/skill_channel.py`, `backend/pipeline/pipeline.py`, `backend/pipeline/runner.py`). This issue assembles the trace from the step events 03/05 already emit via `events.emit(run_id, ...)`, and returns it on the `ChannelResult` 05 already produces. (Transitively: 01–04.) + +## Files + +| File | Create/Edit | Purpose (one line) | +|---|---|---| +| `backend/skills/trace.py` | **Create** | Define `journey_trace_v1` shape once + `assemble_trace(step_events, outcome, skill)` + `self_eval(outcome, skill)`; forward-compatible with `distill_trace`. | +| `backend/skills/correction.py` | **Create** | `re_distill(session, skill, traces, provider)` — load → `distill_trace` → version++/evidence-append/replace `skill_md`+`elements` via `to_skill_fields`. | +| `backend/api/v1/skills.py` | **Create** | `POST /api/v1/skills/{skill_id}/redistill` (auth) → calls `correction.re_distill`, returns new `version`; plus a thin `GET` for listing skills if convenient for the dock. | +| `backend/api/v1/__init__.py` | **Edit** | Import `skills` and `v1_router.include_router(skills.router)` (it is **not** registered today). | +| `backend/channels/skill_channel.py` | **Edit** | After the loop, call `assemble_trace(...)` + `self_eval(...)`, append self-eval to `skills.evidence`, return trace on `ChannelResult.metadata["trace"]`. | +| `frontend/src/labs/topology/AgentDock.tsx` | **Edit** | Add a `重蒸技能` action that POSTs to the redistill endpoint via the existing proposal/confirm-style flow; toast the new version. | +| `tests/skills/test_correction.py` | **Create** | Assert re-distill bumps `version` by exactly 1, appends one `evidence` entry, replaces `skill_md`/`elements` (with `distill_trace` stubbed). | +| `tests/skills/__init__.py` | **Create** | Make `tests/skills` a package (dir does not exist yet — only `tests/unit`, `tests/integration`). | + +## Implementation notes + +These are tied to the real symbols in this repo (verified against the current tree). Honor the fixed decisions: **do NOT change `AbstractChannel.collect(config, parameters)`**, reuse the spine, reuse the chat proposal/confirm contract. + +### 1. `backend/skills/trace.py` — the shared `journey_trace_v1` shape (D6, D7) + +`distill_trace` (in `backend/skills/distill.py`) today reads exactly these keys, so the shape **must** include them unchanged: +- `trace["summary"]["domain"]` (→ skill domain; falls back to `"unknown"`) +- `trace["label"]` (→ capability slug fallback) +- `trace["trace_id"]` (→ `source_trace`) + +Define a single builder so both legs target the same shape. Suggested API: + +```python +TRACE_SCHEMA = "journey_trace_v1" + +def assemble_trace( + step_events: list[dict], # one dict per loop step (from the run's step stream) + outcome: dict, # {"status": "success|failed|paused", "milestones_hit": [...], "terminal_check": ...} + *, + domain: str, + label: str, + trace_id: str, + extra: dict | None = None, +) -> dict: + return { + "schema": TRACE_SCHEMA, + "trace_id": trace_id, + "label": label, + "summary": {"domain": domain, **(extra or {})}, + "steps": step_events, # at least one entry per loop step + "outcome": outcome, # success/failed/paused, milestones hit, terminal check + } +``` + +Requirements (acceptance #1): at least `summary.domain`, `label`, `trace_id`, a `steps[]` array (one entry per loop step — each carrying action verb, ref/target, a snapshot digest, result, timing), and an `outcome` block. Keep it **forward-compatible**: `distill_trace` ignores unknown keys, so adding `schema`/`steps`/`outcome` does not break it. Add a doctest or unit assertion that a trace from `assemble_trace` survives a round trip through `distill_trace` (the distiller only needs the 3 keys above). + +`self_eval(outcome, skill)` is a small pure function comparing the run outcome against the skill's `terminal_conditions` and `milestones` (read from `skill.elements` — keys per `distill.ELEMENT_KEYS`: `terminal_conditions`, `milestones`). Return e.g. `{"event": "executed", "passed": bool, "milestones_hit": [...], "terminal_met": bool, "outcome": "...", "trace_id": "...", "at": }`. This dict is what gets appended to `skills.evidence` (a JSON list on the model, default `list`). + +### 2. Assemble + self-eval inside the run (D5, D7) + +The step events already exist: issues 03/05 emit per-step `TaskRunEvent`s through the module-level `backend/pipeline/events.py::emit(run_id, step, message, level, detail, elapsed_ms)` with `step` values like `skill_step`, `skill_extract`, `skill_done` (PRD §6). For trace assembly, the loop should accumulate the same per-step dicts **in memory** as it emits them (don't re-query `TaskRunEvent` rows mid-collect — `emit` is best-effort/fire-and-forget and `collect()` has no DB session; build the `steps[]` list from the loop's own step records and pass it to `assemble_trace`). At loop end (`done` or cap or `awaiting_confirm` abort): +1. build `outcome` (status `success`/`failed`/`paused`, milestones hit, terminal check vs `terminal_conditions`), +2. `trace = assemble_trace(step_records, outcome, domain=..., label=..., trace_id=run_id-or-uuid)`, +3. `ev = self_eval(outcome, skill)` and append it to `skills.evidence` (open a short-lived `AsyncSessionLocal()` session inside `skill_channel` just like `events.emit` does — load the `Skill`, append to its `evidence` list, reassign the attribute so SQLAlchemy detects the JSON mutation, `commit`), +4. return on the existing result: `ChannelResult.ok(items, channel="skill", executed=True, trace=trace, self_eval=ev, awaiting_confirm=)`. `ChannelResult.ok(items, **metadata)` stores everything in `.metadata`, so the trace lands on `ChannelResult.metadata["trace"]` (acceptance #2). Items still flow through normalize/store unchanged (PRD §6 "Extract → records"). + +Note on the inline-skill case: `SkillChannel` today accepts inline `config["skill_md"]` with no DB `Skill` row (see `_resolve_skill_md`). When there is no persisted skill (no `skill_id`/`(domain,capability)`), still build the trace and `self_eval` (best-effort), but skip the `evidence` write (nothing to append to). Guard the evidence write behind "a resolvable Skill row exists". + +### 3. `backend/skills/correction.py` — re-distill (D7) + +`distill.py` gives you everything; do not reimplement extraction. `re_distill` must: +1. load the `Skill` row (by id) and the failing trace(s) — accept already-shaped `journey_trace_v1` dict(s) (caller passes them; the endpoint can accept a trace inline or by reference), +2. resolve the distill provider config the same way the existing distill path does — from a `ModelProvider` via `backend.skills.distill.provider_from_model(mp)` (mirror `runner.run_collection_pipeline` provider resolution: first enabled `ModelProvider` ordered by `created_at`), falling back to `distill._DEFAULT_PROVIDER`, +3. call `spec = await distill_trace(trace, provider)` (if multiple failing traces, distill the most recent / pass them combined — keep v1 simple: one trace), +4. `fields = to_skill_fields(spec)` and write back onto the **existing** row: `skill.version += 1`; `skill.skill_md = fields["skill_md"]`; `skill.elements = fields["elements"]` (reassign for JSON change-tracking); `skill.distill_model = fields["distill_model"]`; `skill.source_trace = fields["source_trace"]`; append one `evidence` entry `{"event": "corrected", "from_version": n, "to_version": n+1, "trace_id": ..., "at": ...}` (reassign `skill.evidence`), +5. `await session.commit()` and return the new version (and updated skill). + +Hard rule (acceptance #3): **no field is hand-patched** — `skill_md`/`elements` come **only** from `to_skill_fields(spec)`. The only manual mutations are `version += 1` and the `evidence` append (the closed-loop bookkeeping the model is designed for — see `Skill` docstring). + +### 4. API endpoint + registration + +`backend/api/v1/skills.py` does **not** exist and is **not** in `backend/api/v1/__init__.py` — create both. Follow the existing router shape (see `backend/api/v1/chat.py` / `sources.py`): `APIRouter(prefix="/skills", tags=["skills"])`, `Depends(get_db)`, return `ApiResponse.ok(...)` from `backend/schemas/common.py`. Endpoint: + +``` +POST /api/v1/skills/{skill_id}/redistill +body: { "trace": , ... } # or a trace reference +-> ApiResponse.ok({"skill_id": ..., "version": , "domain": ..., "capability": ...}) +``` + +Auth: match how the rest of the API authenticates (acceptance #4 says "authenticated"). Reuse the project's existing auth dependency exactly as the other write endpoints do — do not invent a new scheme; if the other v1 routers take no explicit auth dependency in this codebase, apply the same app-level dependency they rely on so this endpoint is no less protected than `/chat/confirm`. Then register: in `backend/api/v1/__init__.py` add `skills` to the import tuple and `v1_router.include_router(skills.router)`. + +### 5. Dock `重蒸技能` action (D7, D8) + +`AgentDock.tsx` already has the proposal→confirm primitives: it POSTs to `/chat`, renders a `Proposal{tool,args,summary,diff}` as an amber confirm card, and on confirm POSTs to `/chat/confirm` (see `confirm()` / the proposal card block). Add a `重蒸技能` affordance that: +- is shown when the current context is a failing skill (e.g. `contextNode.kind === "skill"`, or when a run surfaced `self_eval.passed === false`), +- on click, shows the same confirm-card style ("重新蒸馏技能「…」→ version n+1") and on confirm calls `apiClient.post('/skills/{id}/redistill', { trace })` — reusing the synchronous confirm flow, not auto-firing, +- on success `toast.success` with the new version and call `onApplied()` to refresh. + +Keep it minimal and consistent with the existing dock styling; the point is reuse of the confirm contract, not a new UI paradigm. Do **not** wire any automatic trigger (D8). + +### 6. Do-not-touch / reuse checklist +- `AbstractChannel.collect(config, parameters)` signature — **unchanged** (ADR-0003 D5). Trace + self-eval ride out on `ChannelResult.metadata`. +- Per-step events — reuse `events.emit` (don't add a new event sink). New trace work consumes the loop's own step records. +- Distillation — reuse `distill_trace` / `to_skill_fields` / `provider_from_model` verbatim. Correction = re-distill (D7). +- Proposal/confirm — reuse the `Proposal` shape + dock card (chat.py / AgentDock.tsx). Do not fork a second confirm mechanism. + +## Acceptance criteria + +Falsifiable. Run backend checks from the repo root (`D:/projects/opencli-admin`). + +1. **Shared `journey_trace_v1` shape.** `backend/skills/trace.py` defines the schema with at least `summary.domain`, `label`, `trace_id`, `steps[]` (one entry per loop step), and an `outcome` block, and `assemble_trace(step_events, outcome, ...)` builds it. Forward-compatible with the distiller. Verify: + ```bash + python -c "import asyncio,json; from backend.skills.trace import assemble_trace; from backend.skills import distill; \ + t=assemble_trace([{'action':'navigate','target':'x','result':'ok','ms':5}], {'status':'success','milestones_hit':[],'terminal_check':True}, domain='binance', label='funding rates', trace_id='t1'); \ + print('steps' in t and t['summary']['domain']=='binance' and t['label']=='funding rates' and t['trace_id']=='t1' and 'outcome' in t)" + # prints: True + ``` + And the distiller reads it unchanged: with `distill.call_llm` monkeypatched to return a fixed JSON, `await distill.distill_trace(t)` returns a spec whose `domain == "binance"` and `source_trace == "t1"` (assert in a unit test). + +2. **Every run emits a trace + self-eval.** After an execute run (issue 05 path), the returned `ChannelResult.metadata["trace"]` is a `journey_trace_v1` dict (has `summary.domain`, `steps`, `outcome`), and a `self_eval` result comparing outcome to the skill's `terminal_conditions`/`milestones` is appended to that skill's `skills.evidence` (one new list entry per run when a persisted `Skill` exists). Verify in a unit test that drives `SkillChannel.collect` with a stubbed loop/page: assert `result.metadata["trace"]["schema"] == "journey_trace_v1"` and that the loaded `Skill.evidence` grew by one entry whose `passed` reflects the outcome. + +3. **`re_distill` re-distills, never hand-patches.** `correction.re_distill` loads a `Skill` + failing trace + current `skill_md`, calls `distill_trace`, and writes back: `skills.version` incremented by exactly 1, `evidence` has exactly one new appended entry, and `skill_md`/`elements` are **replaced from `to_skill_fields(spec)`** (no field set by hand). Covered by acceptance #5's test. + +4. **Authenticated re-distill endpoint + dock wiring.** `POST /api/v1/skills/{skill_id}/redistill` exists, is authenticated like the other v1 write endpoints, and returns the new version. Verify: + ```bash + python -c "from backend.main import app; print(any(getattr(r,'path','').endswith('/skills/{skill_id}/redistill') for r in app.routes))" + # prints: True + ``` + And the dock `重蒸技能` button calls it through the existing proposal/confirm-style flow (manual check: with the backend up and a failing skill in context on `/labs/topology`, clicking `重蒸技能` shows a confirm card and, on confirm, toasts the bumped version; the skill row's `version` increments and `evidence` gains a `"corrected"` entry). + +5. **`tests/skills/test_correction.py` passes under `-m "not live"`.** With `distill_trace` stubbed (monkeypatch `backend.skills.correction.distill_trace` — or the symbol it imports — to an async fn returning a fixed spec, e.g. `{"skill_name":"x","scope":"s","skill_md":"NEW MD","procedure":["p"],...,"domain":"d","capability":"c","source_trace":"t1","distill_model":"m"}`), feeding a failing trace through `re_distill` against a seeded `Skill` (version=1, known `skill_md`/`elements`/`evidence`) asserts: `version == 2` (bumped by exactly 1), `len(evidence) == prior + 1`, `skill_md == "NEW MD"`, and `elements` updated from `to_skill_fields`. Use the in-memory SQLite `db_session` fixture from `tests/conftest.py`. Run: + ```bash + pytest tests/skills/test_correction.py -m "not live" -q + # passes; no network, no browser + ``` + +6. **No automatic re-distill is wired.** Re-distill fires **only** from the endpoint/dock (human trigger). Grep proves no auto-after-N-fails policy exists: + ```bash + grep -rIn -e "consecutive" -e "auto.*re.?distill" -e "re.?distill.*auto" backend/skills backend/channels backend/pipeline + # no automatic-trigger hits (only the human-triggered service/endpoint path) + ``` + The self-eval signal is computed and logged to `evidence` (acceptance #2) but does not itself call `re_distill`. + +## Verifying against a real local Chrome + +Acceptance #2 is the only criterion that touches a live run, and it should be tested with **stubs** in the default suite (no browser) so `pytest -m "not live"` stays green. The end-to-end "real Chrome" path is owned by **issue 07** (e2e behind the `live` marker). If you want to smoke-test the trace on real hardware before 07 lands: +1. Have a Chrome reachable by `backend/browser_pool.py` (local/LAN CDP) — same substrate the opencli channel uses; `connect_over_cdp` attaches to the existing context, so a logged-in tab is reused. +2. Trigger a `skill` `DataSource` run (dock "run skill" or `trigger_task`), watch the run-events stream for `skill_step`/`skill_done`, then inspect the run's `ChannelResult.metadata["trace"]` (logged) and the skill's `evidence` JSON for the appended self-eval entry. +3. For correction, mark a run failed (outcome ≠ `terminal_conditions`), click `重蒸技能`, confirm, and verify the skill's `version` incremented and `skill_md`/`elements` changed. Keep this manual; the **automated** browser assertion lives in issue 07. + +## Out of scope / non-goals + +- **Auto-triggered re-distill after N consecutive failures** — v2 (ADR-0003 D8; PRD §1, §7). v1 only computes + logs the self-eval signal; the policy that turns N fails into an automatic re-distill is explicitly deferred. +- **The human record leg ("录这站")** producing the first `journey_trace_v1` from a demonstration — separate TODO. This issue only fixes the shared shape and emits it from execute runs. +- **Cross-process pause/resume** of an `awaiting_confirm` run — v2. +- **NAT / edge-node execution, vision/raw-DOM/screenshot perception, `evaluate(js)`** — rejected/deferred by the ADR; not part of this issue. +- Changing `AbstractChannel.collect` or adding a new run status — out (status `awaiting_confirm` and the migration are owned by issue 04; this issue only *reads* the outcome into the trace/self-eval). diff --git a/docs/skills-issues/07-end-to-end-test-against-a-real-local-chrome-live-m.md b/docs/skills-issues/07-end-to-end-test-against-a-real-local-chrome-live-m.md new file mode 100644 index 00000000..1ff2efb8 --- /dev/null +++ b/docs/skills-issues/07-end-to-end-test-against-a-real-local-chrome-live-m.md @@ -0,0 +1,176 @@ +# 07 End-to-end test against a real local Chrome (live marker) + +> Source of truth: `docs/adr/0003-skill-execute-loop-architecture.md` (decisions D1–D8) and `docs/skills-execute-loop-PRD.md` (§3 flows, §6 integration points, §7 risk *"Windows Playwright install"*, §8 cut-line, issue **07**). This file is self-contained: an implementer should not need to re-derive anything from chat. It assumes issues **01–05** have landed (the skill execute loop is wired end to end through `SkillChannel.collect`); issue **06** (trace emission + re-distill) is optional for the richer trace assertion. + +--- + +## Context + +The skill subsystem closes a **record → distill → store → execute → correct** loop (ADR-0003). Issues 01–06 build the *execute* leg in unit/integration-level isolation against fakes (a stub `SkillPage`, a scripted cheap model). Nothing yet proves the whole v1 path against a **real browser**. This issue adds **one** end-to-end test that drives a genuine local Chrome over CDP and asserts the loop actually *perceives → acts → extracts → ends on `done`*, plus that the headless write-gate aborts on a high-risk action. + +How it fits the loop and which decisions it exercises: +- **D1 (placement: center-side, Playwright over CDP)** — the test connects over CDP to a Chrome from `backend.browser_pool` and drives it in-process via Playwright `connect_over_cdp`, exactly as `SkillChannel.collect` does. Local/LAN only. +- **D2/D3 (injected-JS snapshot + fixed verb set)** — the skill navigates and `extract`s on a deterministic local page; the test asserts an extract record reaches `ChannelResult.items`. +- **D4 + D8 (risk-tiered confirm, headless abort)** — the test exercises the headless gate: a high-risk action with `auto_confirm` **off** must produce an `awaiting_confirm` outcome with **no silent write**. +- **D5 (stay in the spine, no `collect()` contract change)** — the loop emits per-step `TaskRunEvent`s via `backend.pipeline.events.emit`; the test asserts those rows exist. + +The whole test is gated behind the **existing** `live` pytest marker so the default coverage suite (run with `-m 'not live'`, `--cov-fail-under=80`) never needs a browser. This is the v1 cut-line item "e2e against a real local Chrome (behind `live` marker)" and the PRD §7 *"Windows Playwright install"* risk made reproducible. + +--- + +## Scope + +**In scope** +- `tests/skills/test_execute_loop_live.py`, marked `@pytest.mark.live`, that: + - serves a **deterministic local/static page** (a fixture HTML served by a localhost http server inside the test, or a `file://` URL) — no external-site dependence; + - acquires a **real local Chrome CDP endpoint** from `backend.browser_pool` (or a configured endpoint env var) and runs an **inline `SKILL.md`** skill through the real spine (`SkillChannel.collect`, ideally via `run_pipeline` / `run_collection_pipeline`); + - asserts: at least one `extract` record reaches `ChannelResult.items` (or stored records when run through the full pipeline), the loop **ends on `done`**, and `TaskRunEvent`s were emitted for the run's steps (perceive / step / extract / done); + - asserts the **headless gate**: a high-risk action with `auto_confirm` off yields an `awaiting_confirm` outcome and performs **no write**. +- `tests/skills/__init__.py` (package marker; `tests/skills/` does not exist yet). +- `TESTING.md`: a new section documenting how to run the live skill test on **Windows** (`playwright install chromium`, a running Chrome CDP endpoint, the exact `pytest -m live` command, env vars). + +**Out of scope** (deferred to v2 or owned by other issues) +- CI gating changes beyond *honoring* the existing `live` marker (no new CI jobs/workflows; the default suite must keep deselecting `live`). +- Testing NAT/edge-node execution via `agent_server` (v2). +- Any flaky external-site dependence — the page under test is deterministic and local. +- Cross-process pause/resume of an `awaiting_confirm` run (v2). The test asserts the **abort**, not a resume. +- Building the loop/channel/risk-gate themselves (issues 01–06). This issue only *tests* them. + +--- + +## Depends on + +- **05 — Run integration: wire `SkillChannel.collect` into the spine** (`backend/channels/skill_channel.py`, `backend/pipeline/pipeline.py`, `backend/pipeline/runner.py`). Issue 07 cannot pass until 05 makes `SkillChannel.collect` actually perceive/act/extract and return real `ChannelResult.items` + `metadata["awaiting_confirm"]`. (05 transitively requires 01–04.) +- **06 — `journey_trace_v1` emission** is *optional* for this issue: if 06 has landed you may also assert `ChannelResult.metadata["trace"]` is a `journey_trace_v1`-shaped dict; if not, assert only the `TaskRunEvent` step rows. Do **not** block 07 on 06. + +--- + +## Files + +| File | Create / Edit | One-line purpose | +|---|---|---| +| `D:/projects/opencli-admin/tests/skills/test_execute_loop_live.py` | create | The single `live`-marked e2e: real CDP Chrome + deterministic local page → assert extract→items, step events, `done` termination, and the headless `awaiting_confirm` abort. | +| `D:/projects/opencli-admin/tests/skills/__init__.py` | create | Package marker so `tests/skills/` is importable (the dir does not exist yet). | +| `D:/projects/opencli-admin/TESTING.md` | edit | Add a *"技能执行环路 e2e(live marker,Windows)"* section: `playwright install chromium`, launch a Chrome with `--remote-debugging-port`, set the CDP endpoint env var, run `pytest -m live tests/skills/test_execute_loop_live.py`. | + +> Optional helper if the static fixture HTML is large: `tests/skills/fixtures/skill_demo_page.html`. Inlining the HTML as a Python string in the test is also fine and keeps the test self-contained — pick one. + +--- + +## Implementation notes (concrete to this codebase) + +### 1. The `live` marker already exists — reuse it, broaden its meaning +`pyproject.toml` `[tool.pytest.ini_options]` already declares: +```toml +markers = [ + "live: tests that require a running API server and opencli daemon (deselect with -m 'not live')", +] +addopts = "--cov=backend --cov-report=term-missing --cov-fail-under=80" +asyncio_mode = "auto" +testpaths = ["tests"] +``` +- Mark the test `@pytest.mark.live`. Do **not** add a new marker. +- You **may** widen the marker description to also cover *"a local Chrome reachable over CDP"* (one-line edit). Keep the `-m 'not live'` deselect contract intact — that is the whole point. +- `asyncio_mode = "auto"` means `async def test_...` functions run without a per-test decorator (matches existing tests). +- Because the loop writes `TaskRunEvent` rows via the **module-level** `backend.database.AsyncSessionLocal` (see `backend/pipeline/events.py::emit`), the in-memory `db_session` fixture in `tests/conftest.py` is **not** the DB the loop writes to. The live test must use the real configured DB — see step 4. + +### 2. Acquire a real Chrome CDP endpoint from `browser_pool` +The skeleton already does `get_pool().acquire(endpoint=...)` and `connect_over_cdp` happens inside the loop (D1). For the test: +- Read the CDP endpoint from an env var (document it in `TESTING.md`), e.g. `SKILL_LIVE_CDP_ENDPOINT` (fall back to `OPENCLI_CDP_ENDPOINT`, the var `TESTING.md` already uses for Chrome). If unset, **skip** with a clear reason: + ```python + ep = os.environ.get("SKILL_LIVE_CDP_ENDPOINT") or os.environ.get("OPENCLI_CDP_ENDPOINT") + if not ep: + pytest.skip("set SKILL_LIVE_CDP_ENDPOINT to a running Chrome --remote-debugging-port endpoint") + ``` +- Initialize the pool so `get_pool()` resolves and the endpoint is routable. `backend.browser_pool` is a module-level singleton initialized via `init_pool(endpoints, ...)`; the test should call `init_pool([ep])` (local pool) before the run. `pool.acquire(endpoint=ep)` then yields that exact endpoint string; `pool.get_mode(ep)` defaults to `"bridge"` — the skill loop uses Playwright `connect_over_cdp`, so set `pool.set_mode(ep, "cdp")` if the loop branches on mode. +- Pass the endpoint into the channel the same way the spine does: `parameters["chrome_endpoint"] = ep` (see `SkillChannel.collect`, which reads `parameters.get("chrome_endpoint")`). + +### 3. Deterministic local page + inline `SKILL.md` +- **Page**: serve a tiny static HTML with `http.server.ThreadingHTTPServer` on `127.0.0.1:0` in a fixture (yield the `http://127.0.0.1:/` URL, shut it down on teardown), or write a temp `.html` and use its `file://` URL. The page must contain: + - extractable content with a stable selector (e.g. a list of `
` or a ``), so an `extract` action returns a record deterministically; + - exactly one **high-risk** control for the abort case — a ``, or wrap an `` in a ``. The risk classifier from issue 04 (`backend/skills/risk.py`) matches verb + element name/role. +- **Inline `SKILL.md`**: the channel accepts `config["skill_md"]` inline (`_resolve_skill_md` in `backend/channels/skill_channel.py`). Author **two** skills (or two configs): + - a **read-only** skill whose `procedure` is "navigate to the page, extract the items, then done" and whose `terminal_conditions` are satisfied by the extract — drives the happy path; + - a **high-risk** skill whose procedure leads the model to act on the delete/submit control — drives the abort. Include a matching `red_lines` entry so the gate is authoritative (D4: `red_lines` over the generic pattern). +- The cheap model: do **not** require a live LLM in this test. Inject a **deterministic/scripted executor** so the page interaction is real but the *action choice* is fixed (avoids `live`-test flakiness from a model). Drive it through whatever seam issue 03's loop exposes for the executor (e.g. a provider/model-call function you can monkeypatch, or a `config["provider"]` pointing at a fake). The browser side stays real; only the action sequence is scripted: `navigate{url}` → `extract{...}` → `done{...}` for the happy path, and `navigate{url}` → `click{ref=}` for the abort path. + +### 4. Run through the real spine and assert +Prefer exercising the **real** integration surface (D5) over calling internals: +- **Option A (preferred): full pipeline.** Create a `skill` `DataSource` (`channel_type="skill"`, `channel_config={"skill_md": ..., "auto_confirm": False}`) + a `CollectionTask` in the real DB, then call `backend.pipeline.runner.run_collection_pipeline(task_id, {"chrome_endpoint": ep})`. This routes `run_pipeline → collector.collect → SkillChannel.collect`, emits events through `events.emit(run_id, ...)`, and (issue 05) propagates `metadata["awaiting_confirm"]` to set `TaskRun.status = "awaiting_confirm"` in Phase 4. To do this the test needs the real schema present: create tables once against the configured engine, e.g. + ```python + from backend.database import engine, Base, AsyncSessionLocal + import backend.models # noqa: F401 — register all models + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + ``` + (Use the default sqlite DB or a throwaway file DB via `DATABASE_URL`; document this in `TESTING.md`.) +- **Option B (lighter): channel direct.** Call `SkillChannel().collect(config, {"chrome_endpoint": ep, "run_id": run_id})` directly and assert on the returned `ChannelResult`. You still must create a `TaskRun` row first if you want to assert `TaskRunEvent`s (the FK `task_run_events.run_id → task_runs.id`). + +**Assertions — happy path (read-only skill, `auto_confirm` off but no high-risk action reached):** +- `result.success is True` and `result.items` (the `ChannelResult.items` from `ChannelResult.ok(items, ...)`) contains **≥ 1** extracted record with the expected field(s) from the page. If using Option A, equivalently assert `pipeline_result.stored >= 1` (records reached `storer`). +- The loop ended on `done`: assert a `skill_done`-style `TaskRunEvent` exists (the loop ends on `done{}` or max-step cap — D6), and `result.metadata.get("awaiting_confirm")` is falsy. +- `TaskRunEvent` rows exist for the run's steps. Query `TaskRunEvent` by `run_id` and assert the `step` set includes the loop's perceive/step/extract/done markers. Per PRD §6 the new `step` values are: `skill_perceive`, `skill_step`, `awaiting_confirm`, `skill_extract`, `skill_done`, `self_eval` (`TaskRunEvent.step` is free-text `String(50)`, so match on whatever issue 03/04 actually emit — read those modules to get the exact strings; the assertion should require at least one perceive, one step/extract, and one done event). +- *(Optional, only if issue 06 landed)* `result.metadata["trace"]` is a dict with `summary.domain`, `label`, `trace_id`, a `steps[]` array, and an `outcome` block (the `journey_trace_v1` shape `distill_trace` reads). + +**Assertions — headless abort (high-risk skill, `auto_confirm` off):** +- The run does **not** silently perform the write. Assert an `awaiting_confirm` `TaskRunEvent` was emitted **and** the result signals the pause: `result.metadata.get("awaiting_confirm") is True` (Option B), or `TaskRun.status == "awaiting_confirm"` after `run_collection_pipeline` (Option A — issue 05 must set this in runner Phase 4, *not* force `completed`/`failed`). +- Assert the page side effect did **not** happen — e.g. the delete button's click handler sets a DOM flag (`window.__deleted`) or a counter; after the run, evaluate the page and assert the flag is still false. This is the load-bearing *"no silent write"* check (PRD §7 *"Risk classifier false-negatives are the danger"*). +- Whatever was extracted *before* the abort still flows through (PRD Flow B step 4) — if the high-risk skill extracts first, assert those items are present too. + +### 5. Honor the fixed decisions (do not regress the design) +- **Do NOT change `AbstractChannel.collect(config, parameters)`** — the test calls it with the existing 2-arg signature; `run_id`/`chrome_endpoint` ride inside `parameters` (D5). `backend/channels/base.py` is frozen by this contract. +- **Reuse the spine**: events via `backend.pipeline.events.emit`; extract via `ChannelResult.ok(items, ...)` → `normalizer`/`storer`; paused status via `ChannelResult.metadata["awaiting_confirm"]` → `PipelineResult.metadata` → runner. Don't invent a parallel test-only path that bypasses these — the point is to prove the real wiring. +- **No `evaluate(js)` in the action space** (D3). The test may use Playwright `page.evaluate` for *its own* assertions (reading `window.__deleted`), but the *skill* must only use the fixed verb set. +- **Local/LAN only** (D1/D8). The CDP endpoint is `127.0.0.1`; no NAT/agent path. + +### 6. `TESTING.md` — Windows reproducibility section +Add a section (Chinese, matching the file's existing voice) covering, for a fresh dev on `win32`: +1. Install Playwright + its Chromium driver: + ```bash + uv pip install playwright # or: pip install playwright (already a backend dep after issue 01) + playwright install chromium + ``` + Note (from PRD §7): the loop *connects over CDP* to an already-running Chrome, so the bundled Chromium is needed for the **driver**, not necessarily a second browser. +2. Launch a local Chrome with a CDP debug port (Windows path): + ```powershell + & "C:\Program Files\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 ` + --no-first-run --no-default-browser-check + ``` +3. Point the test at it and run **only** the live skill test: + ```powershell + $env:SKILL_LIVE_CDP_ENDPOINT = "http://127.0.0.1:9222" + pytest -m live tests/skills/test_execute_loop_live.py + ``` +4. State explicitly that the **default** suite excludes it: + ```powershell + pytest -m "not live" # the --cov-fail-under=80 suite; no browser required + ``` +5. Mention the DB used by the live test (default sqlite or a throwaway `DATABASE_URL`) and that `playwright install chromium` is a one-time setup per machine. + +--- + +## Acceptance criteria (falsifiable) + +1. **Deselected by default.** `pytest -m 'not live'` does **not** run `tests/skills/test_execute_loop_live.py`, and the default suite still passes `--cov-fail-under=80` **without a browser**. Verify: + ```powershell + pytest -m "not live" --collect-only -q | Select-String "test_execute_loop_live" # → no matches + pytest -m "not live" # → passes, no Chrome needed + ``` +2. **Real CDP + deterministic page → items + `done`.** With a Chrome CDP endpoint running and `SKILL_LIVE_CDP_ENDPOINT` set, `pytest -m live tests/skills/test_execute_loop_live.py` passes; the read-only skill connects over CDP (endpoint from `backend.browser_pool` / the env var), runs on a deterministic local page, and the test asserts **≥ 1** extract record reached `ChannelResult.items` (or `pipeline_result.stored >= 1`) and the loop ended on `done` (a `skill_done` event present; `metadata["awaiting_confirm"]` falsy). +3. **Step events emitted.** The live test queries `TaskRunEvent` by `run_id` and asserts events for the run's steps were written — at least one perceive event, at least one step/extract event, and one done event (exact `step` strings taken from issues 03/04, e.g. `skill_perceive` / `skill_step` / `skill_extract` / `skill_done`). +4. **Headless gate exercised, no silent write.** With `auto_confirm` off and a high-risk action (matching `submit|pay|post|delete` or the skill's `red_lines`), the live test asserts an `awaiting_confirm` outcome (`ChannelResult.metadata["awaiting_confirm"] is True`, or `TaskRun.status == "awaiting_confirm"` via `run_collection_pipeline`) **and** that the page write did not occur (a DOM flag set by the high-risk control is still false after the run). +5. **Reproducible on Windows.** `TESTING.md` documents, for a fresh dev: `playwright install chromium`, launching Chrome with `--remote-debugging-port`, setting `SKILL_LIVE_CDP_ENDPOINT`, the exact `pytest -m live` command, and the `pytest -m "not live"` default — sufficient to reproduce the test on `win32` from scratch. + +**How to verify against a real local Chrome:** start Chrome with `--remote-debugging-port=9222`, `set SKILL_LIVE_CDP_ENDPOINT=http://127.0.0.1:9222`, run `pytest -m live tests/skills/test_execute_loop_live.py`; with no endpoint set the test must `pytest.skip(...)` with an actionable message rather than fail. + +--- + +## Out of scope / non-goals + +- **No new CI jobs or workflow files.** Honor the existing `live` marker only; the default `-m 'not live'` run must keep working browser-free. +- **No external-site dependence.** The page under test is a deterministic local/static fixture (localhost http server or `file://`). Do not point the test at a real website. +- **No NAT/edge execution** via `agent_server` (v2). CDP endpoint is local/LAN only. +- **No cross-process resume.** The abort path asserts the run stops at `awaiting_confirm`; resuming it is v2. +- **No live LLM requirement.** The cheap model's action choice is scripted/injected so the test is deterministic; only the **browser** is real. Testing real model tool-calling is covered by issue 03's unit tests, not here. +- **Do not implement the loop, channel, risk gate, trace, or re-distill here** — those are issues 01–06. Issue 07 is the e2e proof only. diff --git a/apps/web/Dockerfile b/experiments/next-web/Dockerfile similarity index 100% rename from apps/web/Dockerfile rename to experiments/next-web/Dockerfile diff --git a/experiments/next-web/README.md b/experiments/next-web/README.md new file mode 100644 index 00000000..9c082de9 --- /dev/null +++ b/experiments/next-web/README.md @@ -0,0 +1,7 @@ +# Next Web Experiment + +This directory contains the previous `apps/web` Next.js shell. + +`frontend/` is the only production frontend mainline for v0.4. Do not wire this +experiment into Docker, CI, or the default navigation unless the project +explicitly reopens a Next.js migration. diff --git a/apps/web/next.config.ts b/experiments/next-web/next.config.ts similarity index 100% rename from apps/web/next.config.ts rename to experiments/next-web/next.config.ts diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 4ae174f4..f4e6b34e 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,4 +1,4 @@ -node_modules/ -dist/ -.env* -*.md +node_modules/ +dist/ +.env* +*.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 163c1095..7ba79086 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,24 +1,24 @@ -# ── Stage 1: build React app ────────────────────────────────────────────────── -ARG REGISTRY= -FROM ${REGISTRY}node:22-alpine AS builder - -WORKDIR /app - -COPY package.json package-lock.json* ./ -RUN npm ci --legacy-peer-deps - -COPY . . -RUN npm run build - -# ── Stage 2: serve with nginx ───────────────────────────────────────────────── -ARG REGISTRY= -FROM ${REGISTRY}nginx:1.27-alpine AS runtime - -# Copy built assets -COPY --from=builder /app/dist /usr/share/nginx/html - -# Nginx config: SPA routing + proxy /api to backend -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +# ── Stage 1: build React app ────────────────────────────────────────────────── +ARG REGISTRY= +FROM ${REGISTRY}node:22-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --legacy-peer-deps + +COPY . . +RUN npm run build + +# ── Stage 2: serve with nginx ───────────────────────────────────────────────── +ARG REGISTRY= +FROM ${REGISTRY}nginx:1.27-alpine AS runtime + +# Copy built assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# Nginx config: SPA routing + proxy /api to backend +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/components.json b/frontend/components.json index 70e29164..de2b6655 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -1,21 +1,21 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "src/index.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/i18n-localization-audit.md b/frontend/i18n-localization-audit.md new file mode 100644 index 00000000..764fec48 --- /dev/null +++ b/frontend/i18n-localization-audit.md @@ -0,0 +1,655 @@ +# i18n 本地化清单(自动扫描) + +以下仅列出包含中文字符但未命中基础 `t(` 引用模式的代码行(用于人工复核,不代表完整清单)。 + + +## frontend\src\components\AgentFlightBoard.tsx +- 99: manual: '手动', +- 100: scheduled: '定时', +- 159: return { kind: 'model', role: 'Model Call', title: step \|\| 'AI 处理' } +- 162: return { kind: 'tool', role: 'Tool', title: step \|\| '工具执行' } +- 165: return { kind: 'store', role: 'Data', title: step \|\| '数据入库' } +- 168: return { kind: 'notify', role: 'Notify', title: step \|\| '通知分发' } +- 171: return { kind: 'output', role: 'Output', title: step \|\| '结果' } +- 173: return { kind: 'agent', role: 'Agent', title: step \|\| '运行阶段' } +- 222: title: '触发任务', +- 230: title: '采集源', +- 238: title: '处理数据', +- 247: title: run.status === 'failed' ? '运行失败' : '生成结果', +- 248: message: run.status === 'failed' ? '等待事件详情' : '记录已进入控制台', +- 459: 运行故事板} /> +- 460:
暂无运行记录
+- 471:

运行故事板

+- 504:

运行链路

+- 584:
选择一次运行查看详情
+ +## frontend\src\components\ChannelConfigForm.tsx +- 57: aria-label={ariaLabel ?? placeholder ?? '配置文本'} +- 83: aria-label={ariaLabel ?? placeholder ?? '配置数字'} +- 108: aria-label={ariaLabel ?? '配置选项'} +- 164: aria-label="删除参数行" +- 534: { group: '🇨🇳 国内', label: '小红书 · 搜索', site: 'xiaohongshu', command: 'search', +- 536: argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, +- 538: { group: '🇨🇳 国内', label: '小红书 · 用户笔记', site: 'xiaohongshu', command: 'user', +- 540: argHints: { id: '用户 ID(从主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, +- 542: { group: '🇨🇳 国内', label: 'Bilibili · 热门视频', site: 'bilibili', command: 'hot', +- 544: argHints: { limit: '返回条数(默认 20)' } }, +- 546: { group: '🇨🇳 国内', label: 'Bilibili · 排行榜', site: 'bilibili', command: 'ranking', +- 548: argHints: { limit: '返回条数(默认 20)' } }, +- 550: { group: '🇨🇳 国内', label: 'Bilibili · 关注动态', site: 'bilibili', command: 'dynamic', +- 552: argHints: { limit: '返回条数(默认 20)' } }, +- 554: { group: '🇨🇳 国内', label: 'Bilibili · 收藏夹', site: 'bilibili', command: 'favorite', +- 556: argHints: { limit: '返回条数(默认 20)' } }, +- 558: { group: '🇨🇳 国内', label: 'Bilibili · 用户视频', site: 'bilibili', command: 'user-videos', +- 560: argHints: { uid: 'UP 主 UID(从个人主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, +- 562: { group: '🇨🇳 国内', label: '知乎 · 热榜', site: 'zhihu', command: 'hot', +- 564: argHints: { limit: '返回条数(默认 20)' } }, +- 566: { group: '🇨🇳 国内', label: '知乎 · 问题回答', site: 'zhihu', command: 'question', +- 568: argHints: { id: '问题 ID(从 URL 中获取,如 /question/123456789)', limit: '返回答案数(默认 10)' } }, +- 570: { group: '🇨🇳 国内', label: '微博 · 热搜', site: 'weibo', command: 'hot', +- 574: { group: '🇨🇳 国内', label: 'V2EX · 热门话题', site: 'v2ex', command: 'hot', +- 576: argHints: { limit: '返回条数(默认 20)' } }, +- 578: { group: '🇨🇳 国内', label: 'V2EX · 最新话题', site: 'v2ex', command: 'latest', +- 580: argHints: { limit: '返回条数(默认 20)' } }, +- 582: { group: '🇨🇳 国内', label: '雪球 · 动态', site: 'xueqiu', command: 'hot', +- 584: argHints: { limit: '返回条数(默认 20)' } }, +- 586: { group: '🇨🇳 国内', label: '雪球 · 热门股票', site: 'xueqiu', command: 'hot-stock', +- 588: argHints: { limit: '返回条数(默认 20,最大 50)' } }, +- 590: { group: '🇨🇳 国内', label: '雪球 · 股票行情', site: 'xueqiu', command: 'stock', +- 592: argHints: { symbol: 'A 股代码(如 601318 中国平安)或港股(如 00700 腾讯)' } }, +- 594: { group: '🇨🇳 国内', label: '什么值得买 · 搜索', site: 'smzdm', command: 'search', +- 596: argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, +- 598: { group: '🇨🇳 国内', label: 'Boss直聘 · 职位搜索', site: 'boss', command: 'search', +- 600: argHints: { keyword: '职位名称或关键词(必填,如 "前端工程师")', city: '城市代码(101010100=北京,101020100=上海,101280100=广州,101280600=深圳)', limit: '返回条数(默认 20)' } }, +- 602: { group: '🇨🇳 国内', label: '携程 · 目的地搜索', site: 'ctrip', command: 'search', +- 604: argHints: { query: '目的地或景点名称(必填,如 "三亚")', limit: '返回条数(默认 15)' } }, +- 606: { group: '🇨🇳 国内', label: '小宇宙 · 播客信息', site: 'xiaoyuzhou', command: 'podcast', +- 608: argHints: { id: '播客 ID(从 URL 获取,如 5e280fbd418a84a0463d3e3b)' } }, +- 610: { group: '🇨🇳 国内', label: '小宇宙 · 单集列表', site: 'xiaoyuzhou', command: 'podcast-episodes', +- 612: argHints: { id: '播客 ID(同上)', limit: '返回集数(最多 15,受 SSR 限制)' } }, +- 618: argHints: { limit: '返回条数(1–500)' } }, +- 622: argHints: { limit: '返回条数(默认 20)' } }, +- 626: argHints: { query: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, +- 636: argHints: { limit: '返回条数(默认 20)' } }, +- 640: argHints: { query: '搜索关键词,支持运算符(必填,如 "AI lang:en")', limit: '返回条数(默认 20)' } }, +- 644: argHints: { limit: '返回条数(默认 20)' } }, +- 648: argHints: { limit: '返回条数(默认 20)' } }, +- 652: argHints: { subreddit: '子版块名称(可选,留空则为全站热门,如 "programming")', limit: '返回条数(默认 20)' } }, +- 656: argHints: { limit: '返回条数(默认 20)' } }, +- 660: argHints: { query: '搜索关键词(必填)', limit: '返回条数(最多 10)' } }, +- 664: argHints: { query: '职位名称或关键词(必填)', limit: '返回条数(默认 20)' } }, +- 668: argHints: { symbol: '股票代码(如 AAPL、GOOGL、TSLA、SPY)' } }, +- 672: argHints: { symbol: '股票代码(如 AAPL、SPY、QQQ)' } }, +- 680: xiaohongshu: '小红书', bilibili: 'Bilibili', zhihu: '知乎', +- 681: weibo: '微博', v2ex: 'V2EX', xueqiu: '雪球', +- 682: smzdm: '什么值得买', boss: 'Boss直聘', ctrip: '携程', xiaoyuzhou: '小宇宙', +- 697: { label: '🇨🇳 国内', sites: ['xiaohongshu','bilibili','zhihu','weibo','v2ex','xueqiu','smzdm','boss','ctrip','xiaoyuzhou'] }, +- 736: aria-label="参数名" +- 741: placeholder="参数名" +- 744: aria-label={hintText ?? '参数值'} +- 749: placeholder={hintText ?? '参数值'} +- 753: aria-label="删除参数" +- 768: aria-label="添加参数" +- 774: +- 780: +- 788: 添加参数 +- 852: +- 873: +- 896: { value: 'json', label: 'JSON(推荐)' }, + +## frontend\src\components\CommandPalette.tsx +- 41: keywords: ['dashboard', 'overview', '仪表盘', '概览'], +- 49: keywords: ['topology', 'graph', 'node', 'flow', '拓扑', '节点'], +- 57: keywords: ['records', 'data', 'notes', '采集记录', '笔记', '数据'], +- 65: keywords: ['tasks', 'runs', 'failed', '任务', '失败', '运行'], +- 73: keywords: ['sources', 'channels', 'feeds', '数据源', '来源'], +- 81: keywords: ['nodes', 'browser', 'agent', '采集节点', '浏览器'], +- 89: keywords: ['agents', 'ai', 'prompt', '智能体'], +- 97: keywords: ['providers', 'models', 'keys', '模型', '提供商'], +- 105: keywords: ['notifications', 'webhook', 'ack', '通知', '回执'], +- 113: keywords: ['settings', 'preferences', 'configure', '设置', '偏好'], +- 121: keywords: ['workers', 'celery', 'chrome', '工作节点'], + +## frontend\src\components\ConfirmDialog.tsx +- 29: confirmLabel = '确认删除', +- 43: 取消 + +## frontend\src\components\ErrorBoundary.tsx +- 30:

页面渲染出错

+- 36: 重试 + +## frontend\src\components\NotifierConfigForm.tsx +- 275: placeholder="【新采集】{{title}}" +- 282: placeholder={'**来源**:{{source_id}}\n**标题**:{{title}}\n**链接**:{{url}}'} + +## frontend\src\components\Pagination.tsx +- 45: 共 {total} 条 +- 53: aria-label="上一页" +- 84: aria-label="下一页" + +## frontend\src\components\StatusBadge.tsx +- 21: pending: '待执行', +- 22: running: '采集中', +- 23: ai_processing: 'AI 处理中', +- 24: completed: '已完成', +- 25: failed: '失败', +- 26: cancelled: '已取消', +- 27: raw: '原始', +- 28: normalized: '已归一化', +- 29: ai_processed: '已处理', +- 30: sent: '已发送', +- 31: acked: '已回执', +- 32: not_required: '无需回执', +- 33: online: '在线', +- 34: offline: '离线', + +## frontend\src\lib\collectionWorkflowModel.ts +- 299: label: actionId === 'task.trigger' ? '再次触发' : '触发采集', +- 300: description: actionId === 'task.trigger' ? '再次触发一次采集任务' : '触发一次采集', + +## frontend\src\lib\nodeActions.ts +- 42: label: '触发采集', +- 43: description: '直接触发一次数据源采集任务', +- 53: message: '采集已提交', +- 68: label: '打开源详情', +- 69: description: '跳转到数据源列表并聚焦该来源', +- 79: label: '再次触发', +- 80: description: '基于任务源 ID 触发一次采集', +- 91: message: '任务缺少 source_id', +- 98: message: '采集已提交', +- 114: label: '查看详情', +- 115: description: '跳转到任务页面', +- 125: label: '查看智能体', +- 126: description: '跳转到智能体配置页', +- 170: return '未知错误' + +## frontend\src\lib\nodeRunService.ts +- 15: throw new Error('请输入对话指令') +- 21: throw new Error('未识别指令类型,示例:trigger source 或 run task ') +- 26: throw new Error('未识别实体 ID,示例:trigger source ') +- 54: if (/\btask\b\|任务/.test(lowered)) return 'task' +- 55: if (/\bagent\b\|智能体/.test(lowered)) return 'agent' +- 56: if (/\bsource\b\|源\|数据源/.test(lowered)) return 'source' +- 60: return /task\|任务/.test(lowered) +- 62: : /source\|源\|数据源/.test(lowered) +- 71: const isExecute = /(执行\|触发\|trigger\|run\|rerun\|再触发\|启动)/.test(lowered) + +## frontend\src\pages\AgentsPage.tsx +- 50: { key: 'glm', label: 'GLM (智谱)', processor_type: 'openai', base_url: 'https://open.bigmodel.cn/api/paas/v4/', default_model: 'glm-4-flash', needs_api_key: true }, +- 52: { key: 'ollama', label: 'Ollama(本地)', processor_type: 'local', base_url: 'http://localhost:11434', default_model: 'llama3', needs_api_key: false, base_url_editable: true }, +- 53: { key: 'custom', label: '自定义', processor_type: 'openai', base_url: '', default_model: '', needs_api_key: true, base_url_editable: true }, +- 64: source_id: '数据源 ID', +- 65: title: '标题', +- 66: url: '链接', +- 67: content: '正文', +- 68: author: '作者', +- 69: published_at: '发布时间', +- 71: rank: '排名', +- 72: id: '条目 ID', +- 73: likes: '点赞数', +- 74: score: '评分', +- 75: comments: '评论数', +- 76: plays: '播放量', +- 77: play: '播放量', +- 78: views: '浏览量', +- 80: danmaku: '弹幕数', +- 82: heat: '热度', +- 83: answers: '回答数', +- 84: votes: '投票数', +- 86: hot_value: '热度值', +- 87: category: '分类', +- 88: label: '标签', +- 90: subreddit: '子版块', +- 91: upvotes: '赞数', +- 92: section: '栏目', +- 94: symbol: '股票代码', +- 95: price: '价格', +- 96: change: '涨跌额', +- 97: changePercent: '涨跌幅', +- 98: changePct: '涨跌幅', +- 99: open: '开盘价', +- 100: high: '最高价', +- 101: low: '最低价', +- 102: volume: '成交量', +- 103: marketCap: '市值', +- 104: peRatio: '市盈率', +- 105: eps: '每股收益', +- 106: heat_value: '热度值', +- 108: mall: '商城', +- 110: salary: '薪资', +- 111: company: '公司', +- 112: area: '地区', +- 113: experience: '工作经验', +- 114: degree: '学历要求', +- 115: skills: '技能', +- 118: type: '类型', +- 120: subscribers: '订阅数', +- 121: episodes: '期数', +- 122: eid: '单集 ID', +- 123: duration: '时长', +- 125: tweets: '推文数', +- 126: retweets: '转发数', +- 127: replies: '回复数', +- 129: location: '地点', +- 138: label: '内容摘要', +- 139: template: '请对以下内容生成一段简洁的中文摘要(150字以内):\n\n标题:{{title}}\n作者:{{author}}\n来源:{{source_id}}\n\n正文:\n{{content}}\n\n链接:{{url}}', +- 143: label: '关键标签', +- 144: template: '请从以下内容中提取 3-5 个关键标签,用中文逗号分隔,只输出标签,不要其他内容:\n\n标题:{{title}}\n内容:{{content}}', +- 148: label: '情感分析', +- 149: template: '请分析以下内容的情感倾向,按如下格式输出:\n情感:正面/中性/负面\n理由:(一句话解释)\n\n标题:{{title}}\n内容:{{content}}', +- 153: label: '热榜解读', +- 154: template: '以下是一条热榜内容,请简要说明其热度原因和潜在影响(100字以内):\n\n标题:{{title}}\n热度排名:{{extra_rank}}\n来源:{{source_id}}\n链接:{{url}}', +- 158: label: '结构化提取', +- 159: template: '请从以下内容中提取关键信息,以 JSON 格式输出,包含字段:summary(摘要)、keywords(关键词数组)、entities(实体数组):\n\n标题:{{title}}\n内容:{{content}}\n链接:{{url}}', +- 165: { label: '🇨🇳 国内', sites: ['xiaohongshu', 'bilibili', 'zhihu', 'weibo', 'v2ex', 'xueqiu', 'smzdm', 'boss', 'ctrip', 'xiaoyuzhou'] }, +- 304: placeholder="内容摘要助手" +- 321:

模型配置

+- 329: 手动配置 +- 336: 已保存提供商 +- 363: 类型 +- 374: {selectedSavedProvider.api_key ? '••••••••' : '未配置(读环境变量)'} +- 381: (留空使用提供商默认) +- 424: (可选,留空读环境变量) +- 451: 接入点:{provider.base_url} +- 468: 预设: +- 487: placeholder="请分析以下内容:\n\n{{title}}\n{{content}}" +- 499: +- 528: 标准字段 +- 530: (划线表示该站点无此字段) +- 560:

扩展字段

+- 576:

无扩展字段

+- 617: onSuccess: () => { qc.invalidateQueries({ queryKey: ['agents'] }); setShowAdd(false); toast.success('Agent 已保存') }, +- 618: onError: (err) => toast.error(err instanceof Error ? err.message : '操作失败'), +- 623: onSuccess: () => { qc.invalidateQueries({ queryKey: ['agents'] }); setEditAgent(null); toast.success('Agent 已保存') }, +- 624: onError: (err) => toast.error(err instanceof Error ? err.message : '操作失败'), +- 634: onSuccess: () => { qc.invalidateQueries({ queryKey: ['agents'] }); toast.success('已删除') }, +- 635: onError: (err) => toast.error(err instanceof Error ? err.message : '删除失败'), +- 734: 编辑 + +## frontend\src\pages\BrowsersPage.tsx +- 137: label: 'Browser Bridge 模式', +- 140: tech: 'opencli 1.0 · daemon.js + opencli Browser Bridge 扩展', +- 141: desc: 'Chrome 内置 "opencli Browser Bridge" 扩展通过 WebSocket 连接 daemon.js 常驻进程,由 daemon 代理执行浏览器操作。Cookie、登录态由真实 Chrome 保存,容器重启不丢失。', +- 142: pros: ['登录状态持久保留,适合需要账号的站点(B站、小红书等)', '浏览器行为接近真实用户,抗检测能力强', 'daemon 常驻保持连接,任务触发延迟低'], +- 146: label: 'CDP 直连模式', +- 149: tech: 'opencli 0.9 · Playwright 直连 Chrome DevTools Protocol', +- 150: desc: 'API 容器通过 Playwright 直接连接 Chrome 的 DevTools Protocol 端口(:19222)控制浏览器,不经过扩展或 daemon 中转。', +- 151: pros: ['无需扩展参与,链路更简单,故障点更少', '适合无需登录的公开页面抓取', '每次任务独立连接,状态隔离'], +- 199:

控制模式

+- 242: Agent 地址 (可选,留空则为本地实例) +- 251:

填写后须选择连接协议(需配合 COLLECTION_MODE=agent)

+- 257:

连接协议

+- 260: { value: 'http' as const, label: 'HTTP', desc: '局域网 / 代理可达,中心主动请求 Agent' }, +- 261: { value: 'ws' as const, label: 'WS', desc: '反向 WebSocket,Agent 主动连中心,适合 NAT / 跨网场景' }, +- 494: ⚠ CDP 会启动新 Chrome,本地请用 Bridge +- 545: {available ? '在线' : '空闲'} +- 549: {isDockerEndpoint ? label : '本地 Chrome'} +- 621: {p === 'http' ? 'HTTP(局域网 / 代理)' : 'WS(反向连接)'} +- 632: 保存 +- 638: 取消 +- 646: title="点击编辑 Agent 地址" +- 659: 未配置 — 点击设置 +- 714: toast.success('采集模式已切换') +- 716: onError: (err) => toast.error(err instanceof Error ? err.message : '切换失败'), +- 732: onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['browser-bindings'] }); toast.success('站点绑定已添加') }, +- 733: onError: (err) => toast.error(err instanceof Error ? err.message : '绑定失败'), +- 738: onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['browser-bindings'] }); toast.success('已解绑') }, +- 739: onError: (err) => toast.error(err instanceof Error ? err.message : '解绑失败'), +- 744: onError: (err) => toast.error(err instanceof Error ? err.message : '添加实例失败'), +- 771: toast.success('实例已移除') +- 774: onError: (err) => toast.error(err instanceof Error ? err.message : '移除实例失败'), +- 868:

本地浏览器

+- 896:

Docker 实例

+ +## frontend\src\pages\DashboardPage.tsx +- 26: all: '全部', +- 27: today: '今天', +- 28: yesterday: '昨天', +- 29: '7d': '7 天内', +- 30: '30d': '30 天内', +- 31: custom: '自定义', +- 35: manual: '手动', +- 36: scheduled: '定时', +- 148: 持平 +- 284: label="今日执行" +- 305: label="成功执行次数" +- 312: label="失败执行次数" +- 319: label="成功率" +- 321: sub={runs.total > 0 ? `${runs.success} / ${runs.total}` : '暂无数据'} +- 335:

7 天任务执行趋势

+- 355: +- 356: +- 357: +- 366:

7 天新增采集量

+- 385: +- 410:
+- 412: + +## frontend\src\pages\NodesPage.tsx +- 37: if (min < 1) return '刚刚' +- 118: ws: 'WS 反向通道:Agent 主动连接中心,适合 NAT / 跨网场景,无需开放入站端口。', +- 119: http: 'HTTP 直连:中心主动请求 Agent,适合局域网场景,Agent 需对中心可访问。', +- 122: bridge: 'Bridge(推荐):opencli 通过 Daemon 连接 Chrome,速度快、稳定。', +- 123: cdp: 'CDP:opencli 通过 CDP 协议直连 Chrome,适合兼容性场景。', +- 157:

新增节点

+- 161: Docker 直接运行 +- 164: Shell 脚本 +- 169: 注册模式 +- 171: +- 172: +- 176: 采集模式 +- 184: 网络模式 +- 186: +- 195: +- 196: +- 204:

Host 网络:容器直接使用宿主机网络,无需端口映射,适合 API 运行在宿主机(非 Docker)时使用。仅 Linux 支持。

+- 207: ?

内置 Chrome:镜像自包含 Chromium + Xvfb,无需宿主机提供 Chrome。

+- 208: :

宿主机 Chrome:使用轻量镜像(~100 MB),连接宿主机 Chrome(需提前启动并开启 CDP 端口 9222)。

+- 211:

Shell 脚本:无需 Docker,脚本自动安装 Python 依赖并启动 Agent,有 systemd 时注册为服务。

+- 221: {copied ? '已复制' : '复制'} +- 230: 关闭 +- 282: today: '今天', yesterday: '昨天', '7d': '7 天', '30d': '30 天', all: '全部', +- 312:

加载中…

+- 316: { label: '总执行', value: data.total }, +- 317: { label: '成功', value: data.success, cls: 'text-green-600 dark:text-green-400' }, +- 318: { label: '失败', value: data.failed, cls: 'text-red-500 dark:text-red-400' }, +- 319: { label: '成功率', value: `${data.success_rate}%`, cls: 'text-blue-600 dark:text-blue-400' }, +- 329:

累计采集 {data.records_collected} 条记录

+- 355: title={isOnline ? '在线' : '离线'} +- 388: {isOnline ? '● 在线' : '○ 离线'} +- 408: 统计 +- 598: ⚠ CDP 会启动新 Chrome,本地请用 Bridge +- 653: {available ? '在线' : '空闲'} +- 657: {isDockerEndpoint ? label : '本地 Chrome'} +- 721: {p === 'http' ? 'HTTP(局域网 / 代理)' : 'WS(反向连接)'} +- 731: >保存 +- 735: >取消 +- 750: 未配置 — 点击设置 +- 784: title: '切换为本地模式', +- 785: desc: '切换后,所有采集任务将直连本地 Chrome,不再通过 Agent 节点路由。', +- 786: warn: '已注册的 Agent 节点不会被删除,切换回 Agent 模式后仍可继续使用。', +- 788: btnLabel: '切换为本地模式', +- 791: title: '切换为 Agent 模式', +- 792: desc: '切换后,采集任务将通过已注册的 Agent 节点执行。', +- 793: warn: '请确保至少有一个 Agent 节点在线,否则采集任务将失败。', +- 795: btnLabel: '切换为 Agent 模式', +- 813: {isPending ? '切换中…' : info.btnLabel} +- 820: 取消 +- 874: onSuccess: (newConfig) => { qc.setQueryData(['system-config'], newConfig); toast.success('采集模式已切换') }, +- 875: onError: (err) => toast.error(err instanceof Error ? err.message : '切换失败'), +- 893: onSuccess: () => { qc.invalidateQueries({ queryKey: ['nodes'] }); toast.success('节点已删除') }, +- 894: onError: (err) => toast.error(err instanceof Error ? err.message : '删除失败'), +- 899: onSuccess: () => { qc.invalidateQueries({ queryKey: ['browser-bindings'] }); toast.success('站点绑定已添加') }, +- 900: onError: (err) => toast.error(err instanceof Error ? err.message : '绑定失败'), +- 905: onSuccess: () => { qc.invalidateQueries({ queryKey: ['browser-bindings'] }); toast.success('已解绑') }, +- 906: onError: (err) => toast.error(err instanceof Error ? err.message : '解绑失败'), +- 919: {/* ── 采集模式 ── */} +- 925: 切换后立即生效,影响所有任务的采集路由 +- 940: label: '本地模式', +- 941: desc: '中心直连本地 Chrome(shell 部署),不经过 Agent。适合单机开发或简单采集场景。', +- 947: label: 'Agent 模式', +- 948: desc: '通过 Agent 节点采集,支持本地 Docker 容器或远端多机分布式部署。', +- 976: {/* ── 本地模式:直连本地 Chrome ── */} +- 981:

未检测到本地浏览器端点

+- 982:

请确保 Chrome 以调试模式启动(Bridge 或 CDP)

+- 1006: {/* ── Agent 模式:节点列表 ── */} +- 1011:

Agent 节点

+- 1012:

已注册的 Agent 节点(本地或远端)

+ +## frontend\src\pages\NotificationsPage.tsx +- 130: onSuccess: () => { qc.invalidateQueries({ queryKey: ['notification-rules'] }); setShowAdd(false); toast.success('通知规则已保存') }, +- 131: onError: (err) => toast.error(err instanceof Error ? err.message : '操作失败'), +- 136: onSuccess: () => { qc.invalidateQueries({ queryKey: ['notification-rules'] }); toast.success('已删除') }, +- 137: onError: (err) => toast.error(err instanceof Error ? err.message : '删除失败'), + +## frontend\src\pages\ProvidersPage.tsx +- 20: { value: 'openai', label: 'OpenAI 兼容' }, +- 21: { value: 'local', label: '本地模型(Ollama 等)' }, +- 25: openai: { base_url: 'https://api.openai.com/v1', label: 'OpenAI 官方' }, +- 28: glm: { base_url: 'https://open.bigmodel.cn/api/paas/v4/', label: 'GLM (智谱)' }, +- 30: ollama: { base_url: 'http://localhost:11434', label: 'Ollama 本地' }, +- 140: (OpenAI 兼容接口地址) +- 227: onSuccess: () => { qc.invalidateQueries({ queryKey: ['providers'] }); setShowAdd(false); toast.success('模型服务商已保存') }, +- 228: onError: (err) => toast.error(err instanceof Error ? err.message : '操作失败'), +- 233: onSuccess: () => { qc.invalidateQueries({ queryKey: ['providers'] }); setEditProvider(null); toast.success('模型服务商已保存') }, +- 234: onError: (err) => toast.error(err instanceof Error ? err.message : '操作失败'), +- 244: onSuccess: () => { qc.invalidateQueries({ queryKey: ['providers'] }); toast.success('已删除') }, +- 245: onError: (err) => toast.error(err instanceof Error ? err.message : '删除失败'), + +## frontend\src\pages\RecordsPage.tsx +- 62: return typeof title === 'string' && title.trim() ? title : '未命名记录' +- 107: 选择一条记录开始整理,右侧会固定保留上下文和本地笔记。 +- 155: 操作笔记 +- 157: 本地保存 +- 163: placeholder="下一步、判断、要回看的问题..." +- 175: {expanded ? '收起 JSON' : '展开 JSON'} +- 181:

标准化数据

+- 186:

AI 分析

+- 193: j/k 或方向键移动焦点,Enter 展开当前记录。 +- 307: onSuccess: () => { invalidate(); toast.success('已批量删除') }, +- 308: onError: (err) => toast.error(err instanceof Error ? err.message : '删除失败'), +- 313: onSuccess: () => { invalidate(); setConfirmClearOpen(false); toast.success('已清空') }, +- 314: onError: (err) => toast.error(err instanceof Error ? err.message : '操作失败'), +- 399: header: () => '来源', +- 482: sub={focusedRecord ? focusedRecord.id.slice(0, 8) : '未选择'} +- 489: sub={pageErrorCount > 0 ? '优先处理失败记录' : '本页无失败记录'} +- 496: sub={pageNoteCount > 0 ? '已有本地整理上下文' : '可在右侧添加笔记'} +- 505: title={

搜索、筛选和批量整理

} +- 506: description="筛选只改变当前工作视图,右侧焦点会跟随键盘或点击移动。" +- 528: 一键清空 +- 543: placeholder="搜索标题、内容..." +- 588: title="暂无采集记录" +- 589: description="触发一次采集任务后,数据将在此展示" +- 616:

标准化数据

+- 621:

AI 分析

+- 663: title="确认清空全部记录?" +- 664: description="此操作不可撤销,所有采集记录将被永久删除。" +- 665: confirmLabel={clearAll.isPending ? '清空中…' : '确认清空'} + +## frontend\src\pages\SchedulesPage.tsx +- 32: const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] +- 35: { value: 'once', label: '指定时间' }, +- 36: { value: 'minutely', label: '每 N 分钟' }, +- 37: { value: 'hourly', label: '每小时' }, +- 38: { value: 'daily', label: '每天' }, +- 39: { value: 'weekly', label: '每周' }, +- 40: { value: 'monthly', label: '每月' }, +- 41: { value: 'custom', label: '自定义' }, +- 133:

执行完成后自动禁用

+- 138: +- 142: 分钟执行一次 +- 147: 每小时第 +- 150: +- 153: 执行 +- 160: +- 168: 每月 +- 171: +- 176: {fields.freq === 'daily' ? '每天' : ''} +- 179: +- 184: +- 187: 执行 +- 316: 数据源 * +- 319: +- 333: placeholder="每天早上9点" +- 340: +- 372: +- 373: Agent 模式 +- 385: 自动分配 +- 414: {isConnected ? '● 在线' : '○ 离线'} +- 437: 本地模式 +- 479: {ep.available ? '● 在线' : '○ 离线'} +- 492: 暂无绑定站点 +- 557: onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedules'] }); setShowAdd(false); toast.success('计划已保存') }, +- 558: onError: (err) => toast.error(err instanceof Error ? err.message : '操作失败'), +- 568: onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedules'] }); toast.success('已删除') }, +- 569: onError: (err) => toast.error(err instanceof Error ? err.message : '删除失败'), +- 599: header: '数据源', +- 655: 删除 + +## frontend\src\pages\SettingsPage.tsx +- 69: const message = err instanceof Error ? err.message : '执行失败' + +## frontend\src\pages\SourcesPage.tsx +- 122: hint: '账号环境 / 浏览器采集', +- 129: hint: '订阅流', +- 136: hint: '结构化接口', +- 143: hint: '网页抓取', +- 150: hint: '本地命令', +- 252: 先定节点身份,再补必要参数;采集动作会回到工作台触发。 +- 283: {CHANNEL_META[type].label}{type !== 'opencli' ? '(开发中)' : ''} +- 297: placeholder="给这个节点写一个短备注,方便之后回看" +- 457: +- 458: Agent 模式 +- 486: {isConnected ? '● 在线' : '○ 离线'} +- 503: ? '未选择则自动分配' +- 513: 本地模式 +- 555: {ep.available ? '● 在线' : '○ 离线'} +- 568: 暂无绑定站点 +- 620: if (Number.isNaN(date.getTime())) return '未同步' +- 622: if (diff >= 0 && diff < 60_000) return '刚刚更新' +- 653: return source.description \|\| '未配置目标' +- 730: {source.enabled ? '在线' : '暂停'} +- 750: title={source.enabled ? '暂停节点' : '启用节点'} +- 753: {source.enabled ? '暂停' : '启用'} +- 761: title="立即触发" +- 768: {triggerState === 'ok' ? '已触发' : triggerState === 'err' ? '失败' : '触发'} +- 775: title="编辑节点" +- 778: 编辑 +- 785: title="删除节点" +- 824: const message = result.errors?.join(', ') \|\| '连接失败' +- 828: const message = err instanceof Error ? err.message : '测试失败' +- 841:

没有选中节点

+- 843: 节点详情、测试结果和触发动作会固定在这里。 +- 877: {source.enabled ? '在线' : '暂停'} +- 903: {testStatus.state === 'ok' ? '连接可达' : testStatus.state === 'err' ? '连接失败' : '测试连通'} +- 911: {triggerState === 'ok' ? '任务已触发' : triggerState === 'err' ? '触发失败' : '触发采集'} +- 916: {source.enabled ? '暂停' : '启用'} +- 920: 编辑 +- 924: 删除 +- 946: 暂无标签 +- 1005: { label: '全部', value: 'all' as FilterType, count: sources.length }, +- 1031: toast.success('采集节点已创建') +- 1033: onError: (err) => toast.error(err instanceof Error ? err.message : '创建失败'), +- 1042: toast.success('采集节点已更新') +- 1044: onError: (err) => toast.error(err instanceof Error ? err.message : '更新失败'), +- 1057: toast.success('已删除') +- 1059: onError: (err) => toast.error(err instanceof Error ? err.message : '删除失败'), +- 1072: toast.success('任务已触发') +- 1078: toast.error(_err instanceof Error ? _err.message : '触发失败') +- 1090: title="数据源节点" +- 1091: description="用节点方式组织采集入口、账号环境和触发动作。" +- 1094: 新增节点 +- 1106: title="数据源节点" +- 1107: description="把采集入口当成节点管理:先选节点,再看目标、测试、触发和配置。" +- 1110: 新增节点 +- 1133: sub={channelFilter === 'all' ? '全部类型' : CHANNEL_META[channelFilter].label} +- 1140: sub={selectedSource?.name ?? '未选中'} +- 1150: title={

采集节点图

} +- 1151: description="每个节点都带目标、状态、触发入口和配置摘要。" +- 1167: placeholder="搜索节点、目标、备注..." +- 1236: {searchQuery \|\| channelFilter !== 'all' ? '没有匹配节点' : '还没有采集节点'} +- 1240: ? '换一个筛选条件,或者直接创建新的采集节点。' +- 1241: : '先放一个 OpenCLI、RSS 或 API 节点,后续再把调度和记录串起来。'} +- 1244: 新增节点 +- 1323: description="此操作不可撤销,数据源将被永久删除。" +- 1324: confirmLabel="确认删除" +- 1461: {isEdit ? '编辑采集计划' : '新增采集计划'} +- 1464: 第一版保持轻量:计划只绑定数据源、Cron、时区和启停状态。 +- 1470: +- 1486: +- 1524: 启用计划 +- 1525: 关闭后保留配置,但不会自动触发。 +- 1538: +- 1540: {isEdit ? '保存计划' : '创建计划'} +- 1593: setTestStatus({ state: 'err', message: result.errors?.join(', ') \|\| '连接失败' }) +- 1596: setTestStatus({ state: 'err', message: err instanceof Error ? err.message : '测试失败' }) +- 1608:

选择一个节点

+- 1610: 右侧会切换源详情、计划详情或最近任务详情。 +- 1636: 查看全局拓扑 +- 1640: {schedule.enabled ? '停用' : '启用'} +- 1643: 编辑 +- 1646: 删除 +- 1693: {actionState === 'ok' ? '任务已触发' : actionState === 'err' ? '触发失败' : action.label} +- 1697: +- 1700: 查看全局拓扑 +- 1732: 下次执行:{formatDateTime(stats.nextRunAt)} +- 1744: 查看全局拓扑 +- 1747: 新增计划 +- 1756: {testStatus.state === 'ok' ? '连接可达' : testStatus.state === 'err' ? '连接失败' : '测试'} +- 1774: {actionState === 'ok' ? '已触发' : actionState === 'err' ? '触发失败' : action.label} +- 1778: +- 1784: {source.enabled ? '暂停' : '启用'} +- 1787: 编辑 +- 1790: 删除 +- 1965: { label: '全部', value: 'all' as FilterType, count: sources.length }, +- 2008: toast.success('画布布局已重置') +- 2022: toast.success('采集节点已创建') +- 2024: onError: (err) => toast.error(err instanceof Error ? err.message : '创建失败'), +- 2034: toast.success('采集节点已更新') +- 2036: onError: (err) => toast.error(err instanceof Error ? err.message : '更新失败'), +- 2045: onError: (err) => toast.error(err instanceof Error ? err.message : '更新失败'), +- 2054: toast.success('已删除') +- 2056: onError: (err) => toast.error(err instanceof Error ? err.message : '删除失败'), +- 2066: toast.success('采集计划已创建') +- 2068: onError: (err) => toast.error(err instanceof Error ? err.message : '创建计划失败'), +- 2078: toast.success('采集计划已更新') +- 2080: onError: (err) => toast.error(err instanceof Error ? err.message : '更新计划失败'), +- 2089: toast.success('计划已删除') +- 2091: onError: (err) => toast.error(err instanceof Error ? err.message : '删除计划失败'), +- 2135: toast.error(err instanceof Error ? err.message : '执行失败') +- 2165: toast.error('动作暂不可执行') +- 2171: toast.error('未找到数据源') +- 2184: toast.error('未找到任务') +- 2217: title="数据源工作流" +- 2218: description="用自由画布组织数据源、采集计划和最近任务。" +- 2219: action={} +- 2229: title="数据源工作流" +- 2230: description="源、采集计划和最近任务在同一张画布里拖拽、聚焦、设定计划。" +- 2235: 新增计划 +- 2239: 新增数据源 +- 2256: sub="启用 / 全部计划" +- 2280: title={

采集自由画布

} +- 2281: description="拖拽节点会自动保存到本地布局;计划节点复用现有 Cron Schedule API。" +- 2286: 重置布局 +- 2300: placeholder="搜索源、目标、备注..." +- 2355: {searchQuery \|\| channelFilter !== 'all' ? '没有匹配的数据源' : '还没有数据源'} +- 2359: ? '换一个筛选条件,或者直接创建新的采集节点。' +- 2360: : '先放一个 OpenCLI、RSS 或 API 节点,再在 Inspector 里挂采集计划。'} +- 2363: 新增数据源 +- 2472: description="此操作不可撤销,数据源将被永久删除。" +- 2473: confirmLabel="确认删除" +- 2487: description="计划会被删除,但历史任务不会被修改。" +- 2488: confirmLabel="删除计划" + +## frontend\src\pages\TasksPage.tsx +- 19: trigger: '触发', +- 20: collect: '采集', +- 21: normalize: '归一化', +- 22: store: '入库', +- 23: ai_process: 'AI 处理', +- 24: notify: '通知', +- 25: complete: '完成', +- 26: failed: '失败', +- 62:
加载执行跟踪…
+- 68:
暂无执行日志
+- 102: {nodeUrl && 节点: {nodeUrl}} +- 135: return
加载执行记录…
+- 139: return
暂无执行记录
+- 171: {run.records_collected} 条 +- 254: title="查看执行记录" + +## frontend\src\pages\TopologyPage.tsx +- 289: toast.error(_error instanceof Error ? _error.message : '动作执行失败') +- 297: toast.error('当前节点暂无可执行动作') +- 302: toast.error('此动作当前不可执行') +- 330: 清除聚焦 +- 459: { id: 'skills', label: '缺技能', value: graph.summary.skills.missing + graph.summary.skills.blocked, icon: CircleAlert, tone: 'text-red-300' }, +- 492: { mode: 'flow', label: '流程', icon: SlidersHorizontal }, +- 493: { mode: 'health', label: '健康', icon: CircleAlert }, +- 494: { mode: 'skills', label: '缺能力', icon: CircleAlert }, +- 595: 补齐能力 + +## frontend\src\pages\WorkersPage.tsx +- 52:

单机模式运行中

+- 54: 当前使用本地 asyncio 执行任务,不支持查看工作节点状态。 +- 55: 如需分布式部署,请将 TASK_EXECUTOR 改为 celery 并启动分布式任务服务。 diff --git a/frontend/index.html b/frontend/index.html index f7d71528..6623389c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,13 @@ - - - - - - - OpenCLI Admin - - -
- - - + + + + + + + OpenCLI Admin + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 83de47b2..2eca7fc3 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,29 +1,29 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Gzip - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - # Proxy API calls to backend (HTTP + WebSocket) - location /api/ { - proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_read_timeout 300s; - - # WebSocket support (agent reverse channel) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - # SPA fallback: all unknown paths → index.html - location / { - try_files $uri $uri/ /index.html; - } -} +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Proxy API calls to backend (HTTP + WebSocket) + location /api/ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + + # WebSocket support (agent reverse channel) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # SPA fallback: all unknown paths → index.html + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd990954..17ca7433 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,26 +8,36 @@ "name": "opencli-admin-frontend", "version": "0.1.0", "dependencies": { - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tooltip": "^1.2.8", + "@flowgram.ai/editor": "1.0.11", + "@flowgram.ai/fixed-layout-editor": "1.0.11", + "@flowgram.ai/free-layout-editor": "1.0.11", + "@openbb/ui": "^0.14.17", + "@radix-ui/react-alert-dialog": "^1.1.17", + "@radix-ui/react-dialog": "^1.1.17", + "@radix-ui/react-select": "^2.3.1", + "@radix-ui/react-separator": "^1.1.10", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tooltip": "^1.2.10", "@tanstack/react-query": "^5.62.0", + "@tanstack/react-table": "^8.21.3", + "@xyflow/react": "^12.11.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "elkjs": "^0.11.1", "i18next": "^23.16.0", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-grid-layout": "^2.2.3", "react-i18next": "^15.1.0", "react-router-dom": "^6.28.0", "recharts": "^2.13.3", "sonner": "^1.7.0", + "styled-components": "^6.4.3", "tailwind-merge": "^2.6.1" }, "devDependencies": { @@ -346,6 +356,30 @@ "node": ">=6.9.0" } }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/@dagrejs/graphlib/-/graphlib-2.2.2.tgz", + "integrity": "sha512-CbyGpCDKsiTg/wuk79S7Muoj8mghDGAESWGxcSyhHX5jD35vYMBZochYVFzlHxynpE9unpu6O+4ZuhrLxASsOg==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -790,7 +824,7 @@ }, "node_modules/@floating-ui/core": { "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { @@ -799,7 +833,7 @@ }, "node_modules/@floating-ui/dom": { "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { @@ -809,7 +843,7 @@ }, "node_modules/@floating-ui/react-dom": { "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "resolved": "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { @@ -822,311 +856,2709 @@ }, "node_modules/@floating-ui/utils": { "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "node_modules/@flowgram.ai/background-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/background-plugin/-/background-plugin-1.0.11.tgz", + "integrity": "sha512-e2DccCUABCrxdSom9FdDoEvYDiIDimF5ncnVWJb4xUaQcUyynWwj4gH4j8yimGSPLWDFcUuopA0qqmNTA7un8Q==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "node_modules/@flowgram.ai/command": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/command/-/command-1.0.11.tgz", + "integrity": "sha512-l52bZ2FVl1CQVe02Q0atbrjTk1LaX2QxbymdOB4CsMJLc6crejWn98c21vt7uRzTefyCWWlB7Sz3qqxj4vZhSQ==", "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "node_modules/@flowgram.ai/core": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/core/-/core-1.0.11.tgz", + "integrity": "sha512-DhiWnj/+CsU3T9exRBg0wUTsY6q2iQk0BvRLaDzjI0wnJeRt0SqP2ShXrQqrRRGDkWD4v65Yd1UCxfo3/cDuBg==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@flowgram.ai/command": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "@phosphor/messaging": "^1.3.0", + "@tweenjs/tween.js": "^18", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@flowgram.ai/core/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "bin": { + "nanoid": "bin/nanoid.js" }, "engines": { - "node": ">= 8" + "node": "^18 || >=20" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@flowgram.ai/document": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/document/-/document-1.0.11.tgz", + "integrity": "sha512-sskfzxY4i6CW3jZlrUsj7nlPWd5pSr2tkpcd2tymn+NpeSNUKNPkK2OXh7Bq/hckvG8G7LblrCoHyTEGC0zDOg==", "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "reflect-metadata": "~0.2.2" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@flowgram.ai/document/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@flowgram.ai/editor": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/editor/-/editor-1.0.11.tgz", + "integrity": "sha512-OFg9rHtsgkqZQU4VBcpMyCM3AMJMgZvaBG0q6GzIM7u6h5ZtHgsGa5mMkMBLwiKUZdV1K2WjacQWni87rwOhOA==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/form": "1.0.11", + "@flowgram.ai/form-core": "1.0.11", + "@flowgram.ai/history": "1.0.11", + "@flowgram.ai/history-node-plugin": "1.0.11", + "@flowgram.ai/i18n-plugin": "1.0.11", + "@flowgram.ai/materials-plugin": "1.0.11", + "@flowgram.ai/node": "1.0.11", + "@flowgram.ai/node-core-plugin": "1.0.11", + "@flowgram.ai/node-variable-plugin": "1.0.11", + "@flowgram.ai/playground-react": "1.0.11", + "@flowgram.ai/reactive": "1.0.11", + "@flowgram.ai/redux-devtool-plugin": "1.0.11", + "@flowgram.ai/renderer": "1.0.11", + "@flowgram.ai/shortcuts-plugin": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "@flowgram.ai/variable-plugin": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", - "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "node_modules/@flowgram.ai/fixed-drag-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-drag-plugin/-/fixed-drag-plugin-1.0.11.tgz", + "integrity": "sha512-aNlXgyjIQ7+jR2A6hlEcuQxkEhrB+tMrLIA2qIylq18OoCUeJs6wBoqLx5TIsrJNZY44AuTxkuhCfDCFnJYcxw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/renderer": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" }, "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@flowgram.ai/fixed-history-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-history-plugin/-/fixed-history-plugin-1.0.11.tgz", + "integrity": "sha512-7yGC/i76LM+X/M7+hvEwMqnErdO6XRLTMtyltxiYdDgSznfPdH3+3iRwLQRxpdJfLU0EIKOZh+C2+FTXmsl+Pg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/form-core": "1.0.11", + "@flowgram.ai/history": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "reflect-metadata": "~0.2.2" } }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "node_modules/@flowgram.ai/fixed-layout-core": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-layout-core/-/fixed-layout-core-1.0.11.tgz", + "integrity": "sha512-ndfUUfqb4v9IM3i0iKv7Qy5UPG8wO0I3crkeeIQY4yZKtV3zrrDKrkFTzrk7k1GadAQ9s/ptZr/3dE0bbdWjSQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/renderer": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "reflect-metadata": "~0.2.2" }, "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "node_modules/@flowgram.ai/fixed-layout-editor": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/fixed-layout-editor/-/fixed-layout-editor-1.0.11.tgz", + "integrity": "sha512-IU1BYxABsPX+MiEClVJD2NhCr9U/7PHQ81O9zl0sVi52lVCfGqRIhlVHBBLlKGqL4Ou6aYZlGf5JgK2lTnYinQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/editor": "1.0.11", + "@flowgram.ai/fixed-drag-plugin": "1.0.11", + "@flowgram.ai/fixed-history-plugin": "1.0.11", + "@flowgram.ai/fixed-layout-core": "1.0.11", + "@flowgram.ai/history": "1.0.11", + "@flowgram.ai/reactive": "1.0.11", + "@flowgram.ai/select-box-plugin": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" }, "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@flowgram.ai/form": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/form/-/form-1.0.11.tgz", + "integrity": "sha512-EXVMaiRvIBkhkH6HnUbjZu0WNsSTJKtn61BAfh48ecc4YqumvGPib7/5V0GXZ516Xz97ekXae4LzXrSCQx/g5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@flowgram.ai/reactive": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "fast-equals": "^2.0.0", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9" }, "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "node_modules/@flowgram.ai/form-core": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/form-core/-/form-core-1.0.11.tgz", + "integrity": "sha512-qucie8ekXkfJbXMvPSUGR1CZufBZ1qqHbYNITZhxwR3X54MF7vieFD/6fb7H7jic2z5ad4aCOJcIDe5uD0hQjg==", "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "reflect-metadata": "~0.2.2" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "node_modules/@flowgram.ai/form/node_modules/fast-equals": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/fast-equals/-/fast-equals-2.0.4.tgz", + "integrity": "sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==", + "license": "MIT" + }, + "node_modules/@flowgram.ai/form/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "bin": { + "nanoid": "bin/nanoid.js" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": "^18 || >=20" } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "node_modules/@flowgram.ai/free-auto-layout-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-auto-layout-plugin/-/free-auto-layout-plugin-1.0.11.tgz", + "integrity": "sha512-dzcAEYRStjD0qJ5PkODbBh1+6Df1VnStNwaUdPCfb/ePOoUr9GXCdD87/GLDihAUZVojJve0MBCyKW1PPpCM/A==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.2", + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/free-layout-core": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" }, "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "react": ">=16.8", + "react-dom": ">=16.8", + "styled-components": ">=5" } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@flowgram.ai/free-history-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-history-plugin/-/free-history-plugin-1.0.11.tgz", + "integrity": "sha512-HbKs3D+oCvXCH/2i6ZKVtWWxaFenfroa54yAw9oeBNNT4GVeSBQWK3xeu1hgqbQEB4xZlnN+hmQkH9fpsHIKHg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/form-core": "1.0.11", + "@flowgram.ai/free-layout-core": "1.0.11", + "@flowgram.ai/history": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "reflect-metadata": "~0.2.2" }, "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "node_modules/@flowgram.ai/free-hover-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-hover-plugin/-/free-hover-plugin-1.0.11.tgz", + "integrity": "sha512-okbAWSvtFrywCwpQbiztZPENV+bIPYcpZUn3AEKec0D6A4/8cDolWxFEaZ7RrPceUu7ApCTlLakCFBNhNV6+6g==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/free-layout-core": "1.0.11", + "@flowgram.ai/renderer": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/free-layout-core": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-layout-core/-/free-layout-core-1.0.11.tgz", + "integrity": "sha512-W3ohupBjQuicEdQ0s2nGlmnudsqiq3BA0JXwHYhep/4Gv1PFpEIZ5UomA31zqYFS6lpDx53GqAWZIVTPmK4CPg==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/form-core": "1.0.11", + "@flowgram.ai/node": "1.0.11", + "@flowgram.ai/reactive": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/free-layout-core/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@flowgram.ai/free-layout-editor": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-layout-editor/-/free-layout-editor-1.0.11.tgz", + "integrity": "sha512-OCIJJx74f3YJVgZm5lMwMaVdiisE6BMC5+GjsqCdvQkm9wj4PkrMvSvdqTWc+m15v32qqzt87QI+4ZB6Ldo5FQ==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/editor": "1.0.11", + "@flowgram.ai/free-auto-layout-plugin": "1.0.11", + "@flowgram.ai/free-history-plugin": "1.0.11", + "@flowgram.ai/free-hover-plugin": "1.0.11", + "@flowgram.ai/free-layout-core": "1.0.11", + "@flowgram.ai/free-lines-plugin": "1.0.11", + "@flowgram.ai/free-stack-plugin": "1.0.11", + "@flowgram.ai/history": "1.0.11", + "@flowgram.ai/select-box-plugin": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "clsx": "^1.1.1", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/free-layout-editor/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@flowgram.ai/free-lines-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-lines-plugin/-/free-lines-plugin-1.0.11.tgz", + "integrity": "sha512-vPzEvjp4f0QoFtoQYvy5i02gti3grBZmPySu2iPZG4Y0QB7tOd/Nky1LXYWQLcn830koY/NEYsHA4+Glebu91g==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/free-layout-core": "1.0.11", + "@flowgram.ai/free-stack-plugin": "1.0.11", + "@flowgram.ai/renderer": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "bezier-js": "^6.1.4", + "clsx": "^1.1.1", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8", + "styled-components": ">=5" + } + }, + "node_modules/@flowgram.ai/free-lines-plugin/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@flowgram.ai/free-stack-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/free-stack-plugin/-/free-stack-plugin-1.0.11.tgz", + "integrity": "sha512-gbjvJAxzPLCoKzgKuNJAA97WVcURTdmW582TpTeq0FVpIigLU5vQPHHIE7Z2cLgqyvW6slagLSNGObgYVXlP3Q==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/free-layout-core": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8", + "styled-components": ">=5" + } + }, + "node_modules/@flowgram.ai/history": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/history/-/history-1.0.11.tgz", + "integrity": "sha512-81qjDbqPyPKMqugW69F51JzSHyVc1XoIAYYFeb5aKThdAUWOa3qbxD4yTGIHDCP8+4HYtNyxz43GLnlTeZqC7A==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "reflect-metadata": "~0.2.2" + } + }, + "node_modules/@flowgram.ai/history-node-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/history-node-plugin/-/history-node-plugin-1.0.11.tgz", + "integrity": "sha512-EMJi74r1aLAK2Hsu8j82mCtdYyDqvSXMQ0wTi+SE5cmyTnfC2Fgn+4oIBCSWFcWNbf2ZU0EbUGM4wgSQ8bJPsA==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/form": "1.0.11", + "@flowgram.ai/form-core": "1.0.11", + "@flowgram.ai/history": "1.0.11", + "@flowgram.ai/node": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/history/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@flowgram.ai/i18n": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/i18n/-/i18n-1.0.11.tgz", + "integrity": "sha512-9LqB7gPQH2Wds3Z1OTMlAtU2YxDT0r83JaFHg3OlcAltvI7K3V9PJ+cYD+ux6k8rLuKalED2W0mVJp7UDdqGEw==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/utils": "1.0.11", + "i18n-js": "^4.5.1" + } + }, + "node_modules/@flowgram.ai/i18n-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/i18n-plugin/-/i18n-plugin-1.0.11.tgz", + "integrity": "sha512-1+z/NGiHUUnieH7DcHyLXVULxEVOUkZbVQaXlOEFXm5l3BEh1nyCyimCDrFKijHBfJ3A0Vd9D0z9VES/8CB2Gw==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/i18n": "1.0.11" + } + }, + "node_modules/@flowgram.ai/materials-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/materials-plugin/-/materials-plugin-1.0.11.tgz", + "integrity": "sha512-BvR9a9LFWqth+eDhgPDqGs1oGoqJTta3bi+1Pf5E0p4K5wvxM3UiIwKXwBs7FAuOjdhss1xVNd9RmNQlBm2iow==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/renderer": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/node": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/node/-/node-1.0.11.tgz", + "integrity": "sha512-XB7a+KelQMpZ15jU8HCwB1iksJerBLQSvFe5KT1T/qSfLhWk3kExZ94SsASaiGw/poNMS0xi23O7+zpjRLJQDA==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/form": "1.0.11", + "@flowgram.ai/form-core": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/node-core-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/node-core-plugin/-/node-core-plugin-1.0.11.tgz", + "integrity": "sha512-OLT2cHFZcP/WCoKrP+6CbVvwANbPU2myH3cDLFJzswNhO9ofsaiFw0eCK48oOvzPEZGMhVfrKZTntiW8vXr/hQ==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/form-core": "1.0.11", + "@flowgram.ai/node": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/node-variable-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/node-variable-plugin/-/node-variable-plugin-1.0.11.tgz", + "integrity": "sha512-f3Sa50s8uBjZR0tEYePR5AsL3n2Hq+bY5TOoRw9tsRpK36b8mZBiS5xS2fSPIbVAY3+wq+TqL+4FA+htrcFQ1Q==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/form-core": "1.0.11", + "@flowgram.ai/node": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "@flowgram.ai/variable-plugin": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/node/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@flowgram.ai/playground-react": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/playground-react/-/playground-react-1.0.11.tgz", + "integrity": "sha512-4NORKKYGoH2lyRzf7VW2NFlaHZbE1dpXVAi1ehr27g4L24TUxJCWVRWi8vrCph9hrlucs86Iw1SAFOVP1dnsSQ==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/background-plugin": "1.0.11", + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/shortcuts-plugin": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/reactive": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/reactive/-/reactive-1.0.11.tgz", + "integrity": "sha512-H0F8rexm7dS4zouVKBx/kUlSyRjq41kwo6nF+POCN26cCl65jtqEmJmzZ6xxmuvhK7Rr+Bx0VzrZ8NFZKuHgPQ==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/utils": "1.0.11" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/redux-devtool-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/redux-devtool-plugin/-/redux-devtool-plugin-1.0.11.tgz", + "integrity": "sha512-2ec7+ByYGt2pNpfJ9RTB1sYTyJzjwMf5N2/3fzRCKXEgGYM9Hp6uCfrPNCkyesLAAMX0kTV+LpHRdp1CDiN47A==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/variable-core": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + } + }, + "node_modules/@flowgram.ai/renderer": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/renderer/-/renderer-1.0.11.tgz", + "integrity": "sha512-Lm57xmsxvbkCtDpdWXE6Hf4yQNEF6Hug6G65cWTBkz9vf7uNAh2T29xgyDXvN8Ra1QmhjdfTej0c69xQr7dzCg==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/i18n": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/select-box-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/select-box-plugin/-/select-box-plugin-1.0.11.tgz", + "integrity": "sha512-4lMkU7jseS/BR2PW+t0BmV8LKTNck+AUbCDMwtrqGb6tpMFF7PR0NAXehzD/ifyolynamvt7/tYsT1jWE3VcLQ==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/renderer": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/shortcuts-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/shortcuts-plugin/-/shortcuts-plugin-1.0.11.tgz", + "integrity": "sha512-7GLpX+A1d7qBUn/9rM/LDImgMXI8KjKDPjstw3rBdzb6zvk/Gfw6TmXPJ5neLLLGIM5t56XdYPnQUT0bWI0xmQ==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + } + }, + "node_modules/@flowgram.ai/utils": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/utils/-/utils-1.0.11.tgz", + "integrity": "sha512-ZGYhZBB8uvCnBWTzjRX+vjamILavwuk5m4ks4jx9KwXgKhmk00zkjUZxtHTGBQLOM49gKVk6QVfWxqHKQ0Y/Bw==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1", + "inversify": "^6.0.1", + "nanoid": "^5.0.9", + "reflect-metadata": "~0.2.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/utils/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@flowgram.ai/utils/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@flowgram.ai/variable-core": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-core/-/variable-core-1.0.11.tgz", + "integrity": "sha512-MoN65pu07CTO9kLi+7s1UBLFgBH177WxXJ7YEP8zk7zVvtfA4tHrM5xgcQeavn3WFCTKJENwIGZoeojiB6T97g==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/utils": "1.0.11", + "fast-equals": "^2.0.0", + "inversify": "^6.0.1", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "reflect-metadata": "~0.2.2", + "rxjs": "^7.8.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@flowgram.ai/variable-core/node_modules/fast-equals": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/fast-equals/-/fast-equals-2.0.4.tgz", + "integrity": "sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==", + "license": "MIT" + }, + "node_modules/@flowgram.ai/variable-core/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@flowgram.ai/variable-layout": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-layout/-/variable-layout-1.0.11.tgz", + "integrity": "sha512-ohYi+EfL5ffNqouyi0j5wnx1zxAa29VT7B9N8Yh4viDS9LYKy0EgZWoQ3uQUW2NeIb/WEk4PQmjJCUOKsBurYA==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/free-layout-core": "1.0.11", + "@flowgram.ai/variable-core": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + } + }, + "node_modules/@flowgram.ai/variable-plugin": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@flowgram.ai/variable-plugin/-/variable-plugin-1.0.11.tgz", + "integrity": "sha512-oTm2pvJAZg13n2T+QYvZE4jIQ7XoM/TE3N8iG3sOPkHj8suinW/tYhUaPbWAEZGffBXu7U8r7VZN8uhsP+e4oQ==", + "license": "MIT", + "dependencies": { + "@flowgram.ai/core": "1.0.11", + "@flowgram.ai/document": "1.0.11", + "@flowgram.ai/variable-core": "1.0.11", + "@flowgram.ai/variable-layout": "1.0.11", + "inversify": "^6.0.1", + "reflect-metadata": "~0.2.2" + } + }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@inversifyjs/common": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@inversifyjs/common/-/common-1.4.0.tgz", + "integrity": "sha512-qfRJ/3iOlCL/VfJq8+4o5X4oA14cZSBbpAmHsYj8EsIit1xDndoOl0xKOyglKtQD4u4gdNVxMHx4RWARk/I4QA==", + "license": "MIT" + }, + "node_modules/@inversifyjs/core": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@inversifyjs/core/-/core-1.3.5.tgz", + "integrity": "sha512-B4MFXabhNTAmrfgB+yeD6wd/GIvmvWC6IQ8Rh/j2C3Ix69kmqwz9pr8Jt3E+Nho9aEHOQCZaGmrALgtqRd+oEQ==", + "license": "MIT", + "dependencies": { + "@inversifyjs/common": "1.4.0", + "@inversifyjs/reflect-metadata-utils": "0.2.4" + } + }, + "node_modules/@inversifyjs/reflect-metadata-utils": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/@inversifyjs/reflect-metadata-utils/-/reflect-metadata-utils-0.2.4.tgz", + "integrity": "sha512-u95rV3lKfG+NT2Uy/5vNzoDujos8vN8O18SSA5UyhxsGYd4GLQn/eUsGXfOsfa7m34eKrDelTKRUX1m/BcNX5w==", + "license": "MIT", + "peerDependencies": { + "reflect-metadata": "0.2.2" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@openbb/ui": { + "version": "0.14.17", + "resolved": "https://registry.npmmirror.com/@openbb/ui/-/ui-0.14.17.tgz", + "integrity": "sha512-sah1nCv4dbVy4dPhj5Hg6w9L+H4wjM6jkfjhiuFWRVvqmb0zR0HOicZFAjImfUi8ul6+I+8naI/m4kj1ejSHDw==", + "dependencies": { + "@hookform/resolvers": "^3.6.0", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-tooltip": "~1.0.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "embla-carousel-react": "^8.1.5", + "react-hook-form": "^7.52.0", + "tailwind-merge": "^2.3.0", + "zod": "^3.23.8" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-arrow": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", + "integrity": "sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-popper": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", + "integrity": "sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-rect": "1.0.1", + "@radix-ui/react-use-size": "1.0.1", + "@radix-ui/rect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-tooltip": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", + "integrity": "sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-use-rect": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz", + "integrity": "sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/rect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-use-size": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz", + "integrity": "sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@openbb/ui/node_modules/@radix-ui/rect": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.0.1.tgz", + "integrity": "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@phosphor/algorithm": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@phosphor/algorithm/-/algorithm-1.2.0.tgz", + "integrity": "sha512-C9+dnjXyU2QAkWCW6QVDGExk4hhwxzAKf5/FIuYlHAI9X5vFv99PYm0EREDxX1PbMuvfFBZhPNu0PvuSDQ7sFA==", + "license": "BSD-3-Clause" + }, + "node_modules/@phosphor/collections": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@phosphor/collections/-/collections-1.2.0.tgz", + "integrity": "sha512-T9/0EjSuY6+ga2LIFRZ0xupciOR3Qnyy8Q95lhGTC0FXZUFwC8fl9e8On6IcwasCszS+1n8dtZUWSIynfgdpzw==", + "license": "BSD-3-Clause", + "dependencies": { + "@phosphor/algorithm": "^1.2.0" + } + }, + "node_modules/@phosphor/messaging": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@phosphor/messaging/-/messaging-1.3.0.tgz", + "integrity": "sha512-k0JE+BTMKlkM335S2AmmJxoYYNRwOdW5jKBqLgjJdGRvUQkM0+2i60ahM45+J23atGJDv9esKUUBINiKHFhLew==", + "license": "BSD-3-Clause", + "dependencies": { + "@phosphor/algorithm": "^1.2.0", + "@phosphor/collections": "^1.2.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.17.tgz", + "integrity": "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-avatar/-/react-avatar-1.2.0.tgz", + "integrity": "sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.5.tgz", + "integrity": "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.13", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz", + "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.10.tgz", + "integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.18.tgz", + "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.17", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", + "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-radio-group/-/react-radio-group-1.4.1.tgz", + "integrity": "sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1138,17 +3570,13 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1165,10 +3593,41 @@ } } }, - "node_modules/@radix-ui/react-focus-guards": { + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.13", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1180,15 +3639,31 @@ } } }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1205,14 +3680,11 @@ } } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1223,22 +3695,34 @@ } } }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "node_modules/@radix-ui/react-select": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.3.1.tgz", + "integrity": "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -1255,14 +3739,46 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1279,14 +3795,28 @@ } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.10.tgz", + "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -1303,13 +3833,13 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1326,13 +3856,13 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1344,33 +3874,35 @@ } } }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", + "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1387,13 +3919,13 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1405,13 +3937,13 @@ } } }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.4" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1428,13 +3960,39 @@ } } }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.10.tgz", + "integrity": "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1451,13 +4009,28 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1469,24 +4042,13 @@ } } }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1503,14 +4065,11 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1522,9 +4081,9 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1537,14 +4096,29 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1556,13 +4130,28 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1574,13 +4163,28 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1607,9 +4211,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1622,12 +4226,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1640,13 +4244,28 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1658,12 +4277,35 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1681,9 +4323,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, "node_modules/@remix-run/router": { @@ -2078,6 +4720,45 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "18.6.4", + "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-18.6.4.tgz", + "integrity": "sha512-lB9lMjuqjtuJrx7/kOkqQBtllspPIN+96OvTCeJ2j5FEzinoAXTdAMFnDAQT1KVPRlnYfBrqxtqP66vDM40xxQ==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2135,6 +4816,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -2165,6 +4855,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -2186,6 +4882,25 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2207,14 +4922,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2225,7 +4940,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -2252,6 +4967,48 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.11.0", + "resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.11.0.tgz", + "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.77", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmmirror.com/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2372,6 +5129,22 @@ "node": ">=6.0.0" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, + "node_modules/bignumber.js": { + "version": "11.1.4", + "resolved": "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-11.1.4.tgz", + "integrity": "sha512-AJ9dSeaUGj2xu7tEwmdqb51dqdb633xo4njI9K8ZFfcLrNr0XN8/EPkkZUNaF9fkCblGt2zVwZymesUdGynEkQ==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2455,6 +5228,15 @@ "node": ">= 6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -2526,6 +5308,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2535,6 +5323,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2564,6 +5368,26 @@ "dev": true, "license": "MIT" }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2604,6 +5428,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -2659,6 +5505,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -2704,6 +5559,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2807,6 +5697,40 @@ "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmmirror.com/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmmirror.com/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmmirror.com/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmmirror.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3193,6 +6117,17 @@ "void-elements": "3.1.0" } }, + "node_modules/i18n-js": { + "version": "4.5.3", + "resolved": "https://registry.npmmirror.com/i18n-js/-/i18n-js-4.5.3.tgz", + "integrity": "sha512-5/tT6R9t9qlYqGhxGq9I9Ap3WKUaAMq5aRuO1gqAcUqm6xGbL0jwTAjSFjgbx935BAV8QbEzvQOzE796dUlEfA==", + "license": "MIT", + "dependencies": { + "bignumber.js": "*", + "lodash": "*", + "make-plural": "7.5.0" + } + }, "node_modules/i18next": { "version": "23.16.8", "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", @@ -3225,6 +6160,19 @@ "node": ">=12" } }, + "node_modules/inversify": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/inversify/-/inversify-6.2.2.tgz", + "integrity": "sha512-KB836KHbZ9WrUnB8ax5MtadOwnqQYa+ZJO3KWbPFgcr4RIEnHM621VaqFZzOZd9+U7ln6upt9n0wJei7x2BNqw==", + "license": "MIT", + "dependencies": { + "@inversifyjs/common": "1.4.0", + "@inversifyjs/core": "1.3.5" + }, + "peerDependencies": { + "reflect-metadata": "~0.2.2" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3355,6 +6303,12 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3386,6 +6340,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/make-plural": { + "version": "7.5.0", + "resolved": "https://registry.npmmirror.com/make-plural/-/make-plural-7.5.0.tgz", + "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==", + "license": "Unicode-DFS-2016" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3734,7 +6694,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prop-types": { @@ -3806,6 +6765,60 @@ "react": "^18.3.1" } }, + "node_modules/react-draggable": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/react-draggable/-/react-draggable-4.7.0.tgz", + "integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/react-grid-layout/-/react-grid-layout-2.2.3.tgz", + "integrity": "sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.1.3", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout/node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, + "node_modules/react-hook-form": { + "version": "7.80.0", + "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.80.0.tgz", + "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-i18next": { "version": "15.7.4", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", @@ -3895,6 +6908,20 @@ } } }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", @@ -4048,6 +7075,18 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4149,6 +7188,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4188,6 +7236,48 @@ "node": ">=0.10.0" } }, + "node_modules/styled-components": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/styled-components/-/styled-components-6.4.3.tgz", + "integrity": "sha512-wYXrhu+JmDjZ1Tv7O0OopGTfztbzun43Pjjhh2H+xc0h5A09dwpZ5FJbrifJDcL8g5TA9btpWOX2+iSRuJTExw==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.4.0", + "css-to-react-native": "3.2.0", + "csstype": "3.2.3", + "stylis": "4.3.6" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "css-to-react-native": ">= 3.2.0", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0", + "react-native": ">= 0.68.0" + }, + "peerDependenciesMeta": { + "css-to-react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4348,7 +7438,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4439,6 +7529,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4558,6 +7657,43 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index f662cbac..9358cd5b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,30 +5,51 @@ "type": "module", "scripts": { "dev": "vite", + "test": "node --test src/lib/*.test.ts src/labs/topology/*.test.ts", "build": "tsc -b && vite build", "preview": "vite preview" }, "dependencies": { - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tooltip": "^1.2.8", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", + "@flowgram.ai/editor": "1.0.11", + "@flowgram.ai/fixed-layout-editor": "1.0.11", + "@flowgram.ai/free-layout-editor": "1.0.11", + "@openbb/ui": "^0.14.17", + "@radix-ui/react-alert-dialog": "^1.1.17", + "@radix-ui/react-dialog": "^1.1.17", + "@radix-ui/react-select": "^2.3.1", + "@radix-ui/react-separator": "^1.1.10", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tooltip": "^1.2.10", "@tanstack/react-query": "^5.62.0", + "@tanstack/react-table": "^8.21.3", + "@xyflow/react": "^12.11.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", + "elkjs": "^0.11.1", "i18next": "^23.16.0", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-grid-layout": "^2.2.3", "react-i18next": "^15.1.0", "react-router-dom": "^6.28.0", "recharts": "^2.13.3", + "rete": "^2.0.6", + "rete-area-plugin": "^2.1.5", + "rete-connection-plugin": "^2.0.5", + "rete-context-menu-plugin": "^2.0.6", + "rete-engine": "^2.1.1", + "rete-react-plugin": "^2.1.0", + "rete-render-utils": "^2.0.3", + "rete-scopes-plugin": "^2.1.1", "sonner": "^1.7.0", + "styled-components": "^6.4.3", "tailwind-merge": "^2.6.1" }, "devDependencies": { diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b7..5eec88dd 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/project.json b/frontend/project.json new file mode 100644 index 00000000..ecc84539 --- /dev/null +++ b/frontend/project.json @@ -0,0 +1,54 @@ +{ + "name": "frontend", + "root": "frontend", + "sourceRoot": "frontend/src", + "projectType": "application", + "targets": { + "dev": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm run dev -- --host --port 5173" + ], + "cwd": "frontend" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm run build" + ], + "cwd": "frontend" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm test" + ], + "cwd": "frontend" + }, + "outputs": ["{options.cwd}/coverage"] + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm run build --if-present" + ], + "cwd": "frontend" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npm run lint --if-present" + ], + "cwd": "frontend" + } + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55e72968..19125239 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,33 +1,97 @@ +import { lazy, Suspense, type ReactNode } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import Layout from './components/Layout' -import DashboardPage from './pages/DashboardPage' -import SourcesPage from './pages/SourcesPage' -import TasksPage from './pages/TasksPage' -import RecordsPage from './pages/RecordsPage' -import SchedulesPage from './pages/SchedulesPage' -import NotificationsPage from './pages/NotificationsPage' -import WorkersPage from './pages/WorkersPage' -import AgentsPage from './pages/AgentsPage' -import ProvidersPage from './pages/ProvidersPage' -import NodesPage from './pages/NodesPage' +import { PageLoader } from './components/LoadingSpinner' +import { isTopologyLabEnabled } from './labs/topology/flags' + +const DashboardPage = lazy(() => import('./pages/DashboardPage')) +const SettingsPage = lazy(() => import('./pages/SettingsPage')) +const SourcesPage = lazy(() => import('./pages/SourcesPage')) +const TasksPage = lazy(() => import('./pages/TasksPage')) +const RecordsPage = lazy(() => import('./pages/RecordsPage')) +const SchedulesPage = lazy(() => import('./pages/SchedulesPage')) +const NotificationsPage = lazy(() => import('./pages/NotificationsPage')) +const WorkersPage = lazy(() => import('./pages/WorkersPage')) +const AgentsPage = lazy(() => import('./pages/AgentsPage')) +const ProvidersPage = lazy(() => import('./pages/ProvidersPage')) +const NodesPage = lazy(() => import('./pages/NodesPage')) +const TopologyPage = lazy(() => import('./labs/topology/TopologyPage')) +const NetworkPage = lazy(() => import('./labs/topology/NetworkPage')) +const NodeKitPage = lazy(() => import('./labs/topology/NodeKitPage')) +const WorkflowPage = lazy(() => import('./labs/topology/workflow/WorkflowPage')) + +function LazyRoute({ children }: { children: ReactNode }) { + return }>{children} +} export default function App() { return ( - + }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + } /> + + + + ) : ( + + ) + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> - } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 91f935b6..f8799bbc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,10 +5,25 @@ export const apiClient = axios.create({ headers: { 'Content-Type': 'application/json' }, }) -apiClient.interceptors.response.use( - (res) => res, - (err) => { - const message = err.response?.data?.error || err.message || 'Unknown error' +export const rootClient = axios.create({ + headers: { 'Content-Type': 'application/json' }, +}) + +const normalizeApiError = (err: unknown) => { + if (axios.isAxiosError(err)) { + const message = + err.response?.data?.error || err.response?.data?.detail || err.message || 'Unknown error' return Promise.reject(new Error(message)) } + return Promise.reject(err) +} + +apiClient.interceptors.response.use( + (res) => res, + normalizeApiError +) + +rootClient.interceptors.response.use( + (res) => res, + normalizeApiError ) diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 16329ed7..c9c5c322 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -1,4 +1,4 @@ -import { apiClient } from './client' +import { apiClient, rootClient } from './client.ts' import type { AIAgent, ApiResponse, @@ -6,235 +6,235 @@ import type { BrowserBinding, ChromeEndpoint, CollectedRecord, - CollectionTask, - CronSchedule, - DataSource, - DashboardActivity, - DashboardStats, - EdgeNode, - EdgeNodeEvent, - NodeStats, - NotificationLog, - NotificationRule, - SystemConfig, - TaskRun, - TaskRunEvent, - WorkerNode, -} from './types' - -// ── Dashboard ────────────────────────────────────────────────────────────────── -export const getDashboardStats = (params?: { range?: string; start?: string; end?: string }) => - apiClient.get>('/dashboard/stats', { params }).then((r) => r.data.data) - -export const getDashboardActivity = (params?: { days?: number; tz_offset?: number }) => - apiClient.get>('/dashboard/activity', { params }).then((r) => r.data.data) - -// ── Sources ──────────────────────────────────────────────────────────────────── -export const listSources = (params?: { page?: number; limit?: number; enabled?: boolean }) => - apiClient.get>('/sources', { params }).then((r) => r.data) - -export const getSource = (id: string) => - apiClient.get>(`/sources/${id}`).then((r) => r.data.data) - -export const createSource = (data: Partial) => - apiClient.post>('/sources', data).then((r) => r.data.data) - -export const updateSource = (id: string, data: Partial) => - apiClient.patch>(`/sources/${id}`, data).then((r) => r.data.data) - -export const deleteSource = (id: string) => - apiClient.delete>(`/sources/${id}`).then((r) => r.data) - -export const testSourceConnectivity = (id: string) => - apiClient - .post>(`/sources/${id}/test`) - .then((r) => r.data.data) - -// ── Tasks ────────────────────────────────────────────────────────────────────── -export const listTasks = (params?: { - source_id?: string - status?: string - page?: number - limit?: number -}) => apiClient.get>('/tasks', { params }).then((r) => r.data) - -export const triggerTask = ( - source_id: string, - parameters?: Record, - agent_id?: string, -) => - apiClient - .post>('/tasks/trigger', { - source_id, - parameters: parameters ?? {}, - ...(agent_id ? { agent_id } : {}), - }) - .then((r) => r.data.data) - -export const getTask = (id: string) => - apiClient.get>(`/tasks/${id}`).then((r) => r.data.data) - -export const listTaskRuns = (task_id: string) => - apiClient.get>(`/tasks/${task_id}/runs`).then((r) => r.data) - -export const listRunEvents = (task_id: string, run_id: string) => - apiClient.get>(`/tasks/${task_id}/runs/${run_id}/events`).then((r) => r.data.data) - -// ── Records ──────────────────────────────────────────────────────────────────── -export const listRecords = (params?: { - source_id?: string - task_id?: string - status?: string - search?: string - page?: number - limit?: number -}) => apiClient.get>('/records', { params }).then((r) => r.data) - -export const getRecord = (id: string) => - apiClient.get>(`/records/${id}`).then((r) => r.data.data) - -export const deleteRecord = (id: string) => - apiClient.delete>(`/records/${id}`).then((r) => r.data) - -export const batchDeleteRecords = (ids: string[]) => - apiClient.post>('/records/batch-delete', { ids }).then((r) => r.data) - -export const clearAllRecords = (source_id?: string) => - apiClient.delete>('/records', { params: source_id ? { source_id } : {} }).then((r) => r.data) - -// ── Schedules ────────────────────────────────────────────────────────────────── -export const listSchedules = (params?: { source_id?: string; enabled?: boolean }) => - apiClient.get>('/schedules', { params }).then((r) => r.data) - -export const createSchedule = (data: Partial) => - apiClient.post>('/schedules', data).then((r) => r.data.data) - -export const updateSchedule = (id: string, data: Partial) => - apiClient.patch>(`/schedules/${id}`, data).then((r) => r.data.data) - -export const deleteSchedule = (id: string) => - apiClient.delete>(`/schedules/${id}`).then((r) => r.data) - -// ── Notifications ────────────────────────────────────────────────────────────── -export const listNotificationRules = () => - apiClient.get>('/notifications/rules').then((r) => r.data) - -export const createNotificationRule = (data: Partial) => - apiClient - .post>('/notifications/rules', data) - .then((r) => r.data.data) - -export const updateNotificationRule = (id: string, data: Partial) => - apiClient - .patch>(`/notifications/rules/${id}`, data) - .then((r) => r.data.data) - -export const deleteNotificationRule = (id: string) => - apiClient.delete>(`/notifications/rules/${id}`).then((r) => r.data) - -export const listNotificationLogs = (params?: { rule_id?: string }) => - apiClient - .get>('/notifications/logs', { params }) - .then((r) => r.data) - -// ── Model Providers ──────────────────────────────────────────────────────────── -export const listProviders = () => - apiClient.get>('/providers').then((r) => r.data) - -export const createProvider = (data: Partial) => - apiClient.post>('/providers', data).then((r) => r.data.data) - -export const updateProvider = (id: string, data: Partial) => - apiClient.patch>(`/providers/${id}`, data).then((r) => r.data.data) - -export const deleteProvider = (id: string) => - apiClient.delete>(`/providers/${id}`).then((r) => r.data) - -// ── Agents ───────────────────────────────────────────────────────────────────── -export const listAgents = (params?: { enabled?: boolean }) => - apiClient.get>('/agents', { params }).then((r) => r.data) - -export const createAgent = (data: Partial) => - apiClient.post>('/agents', data).then((r) => r.data.data) - -export const updateAgent = (id: string, data: Partial) => - apiClient.patch>(`/agents/${id}`, data).then((r) => r.data.data) - -export const deleteAgent = (id: string) => - apiClient.delete>(`/agents/${id}`).then((r) => r.data) - -// ── Browser bindings ─────────────────────────────────────────────────────────── -export const listBrowserBindings = () => - apiClient.get>('/browsers/bindings').then((r) => r.data) - -export const createBrowserBinding = (data: { browser_endpoint: string; site: string; notes?: string }) => - apiClient.post>('/browsers/bindings', data).then((r) => r.data.data) - -export const deleteBrowserBinding = (id: string) => - apiClient.delete>(`/browsers/bindings/${id}`).then((r) => r.data) - -export const addChromeInstance = (count = 1, mode: 'bridge' | 'cdp' = 'bridge', agent_url = '', agent_protocol: 'http' | 'ws' | '' = '') => { - const params = new URLSearchParams({ count: String(count), mode }) - if (agent_url) params.set('agent_url', agent_url) - if (agent_protocol) params.set('agent_protocol', agent_protocol) - return apiClient.post>(`/browsers/chrome-instances?${params}`).then((r) => r.data.data) -} - -export const updateChromeInstanceConfig = (endpoint: string, data: { mode?: string; agent_url?: string | null; agent_protocol?: string | null }) => { - const b64 = btoa(endpoint).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') - return apiClient.patch>(`/browsers/instances/${b64}`, data).then((r) => r.data.data) -} - -export const removeChromeInstance = (n: number) => - apiClient.delete>(`/browsers/chrome-instances/${n}`).then((r) => r.data) - -export const restartApi = () => - apiClient.post>('/browsers/restart-api').then((r) => r.data) - -// ── System ───────────────────────────────────────────────────────────────────── + CollectionTask, + CronSchedule, + DataSource, + DashboardActivity, + DashboardStats, + EdgeNode, + EdgeNodeEvent, + NodeStats, + NotificationLog, + NotificationRule, + SystemConfig, + TaskRun, + TaskRunEvent, + WorkerNode, +} from './types.ts' + +// ── Dashboard ────────────────────────────────────────────────────────────────── +export const getDashboardStats = (params?: { range?: string; start?: string; end?: string }) => + apiClient.get>('/dashboard/stats', { params }).then((r) => r.data.data) + +export const getDashboardActivity = (params?: { days?: number; tz_offset?: number }) => + apiClient.get>('/dashboard/activity', { params }).then((r) => r.data.data) + +// ── Sources ──────────────────────────────────────────────────────────────────── +export const listSources = (params?: { page?: number; limit?: number; enabled?: boolean }) => + apiClient.get>('/sources', { params }).then((r) => r.data) + +export const getSource = (id: string) => + apiClient.get>(`/sources/${id}`).then((r) => r.data.data) + +export const createSource = (data: Partial) => + apiClient.post>('/sources', data).then((r) => r.data.data) + +export const updateSource = (id: string, data: Partial) => + apiClient.patch>(`/sources/${id}`, data).then((r) => r.data.data) + +export const deleteSource = (id: string) => + apiClient.delete>(`/sources/${id}`).then((r) => r.data) + +export const testSourceConnectivity = (id: string) => + apiClient + .post>(`/sources/${id}/test`) + .then((r) => r.data.data) + +// ── Tasks ────────────────────────────────────────────────────────────────────── +export const listTasks = (params?: { + source_id?: string + status?: string + page?: number + limit?: number +}) => apiClient.get>('/tasks', { params }).then((r) => r.data) + +export const triggerTask = ( + source_id: string, + parameters?: Record, + agent_id?: string, +) => + apiClient + .post>('/tasks/trigger', { + source_id, + parameters: parameters ?? {}, + ...(agent_id ? { agent_id } : {}), + }) + .then((r) => r.data.data) + +export const getTask = (id: string) => + apiClient.get>(`/tasks/${id}`).then((r) => r.data.data) + +export const listTaskRuns = (task_id: string) => + apiClient.get>(`/tasks/${task_id}/runs`).then((r) => r.data) + +export const listRunEvents = (task_id: string, run_id: string) => + apiClient.get>(`/tasks/${task_id}/runs/${run_id}/events`).then((r) => r.data.data) + +// ── Records ──────────────────────────────────────────────────────────────────── +export const listRecords = (params?: { + source_id?: string + task_id?: string + status?: string + search?: string + page?: number + limit?: number +}) => apiClient.get>('/records', { params }).then((r) => r.data) + +export const getRecord = (id: string) => + apiClient.get>(`/records/${id}`).then((r) => r.data.data) + +export const deleteRecord = (id: string) => + apiClient.delete>(`/records/${id}`).then((r) => r.data) + +export const batchDeleteRecords = (ids: string[]) => + apiClient.post>('/records/batch-delete', { ids }).then((r) => r.data) + +export const clearAllRecords = (source_id?: string) => + apiClient.delete>('/records', { params: source_id ? { source_id } : {} }).then((r) => r.data) + +// ── Schedules ────────────────────────────────────────────────────────────────── +export const listSchedules = (params?: { source_id?: string; enabled?: boolean }) => + apiClient.get>('/schedules', { params }).then((r) => r.data) + +export const createSchedule = (data: Partial) => + apiClient.post>('/schedules', data).then((r) => r.data.data) + +export const updateSchedule = (id: string, data: Partial) => + apiClient.patch>(`/schedules/${id}`, data).then((r) => r.data.data) + +export const deleteSchedule = (id: string) => + apiClient.delete>(`/schedules/${id}`).then((r) => r.data) + +// ── Notifications ────────────────────────────────────────────────────────────── +export const listNotificationRules = () => + apiClient.get>('/notifications/rules').then((r) => r.data) + +export const createNotificationRule = (data: Partial) => + apiClient + .post>('/notifications/rules', data) + .then((r) => r.data.data) + +export const updateNotificationRule = (id: string, data: Partial) => + apiClient + .patch>(`/notifications/rules/${id}`, data) + .then((r) => r.data.data) + +export const deleteNotificationRule = (id: string) => + apiClient.delete>(`/notifications/rules/${id}`).then((r) => r.data) + +export const listNotificationLogs = (params?: { rule_id?: string }) => + apiClient + .get>('/notifications/logs', { params }) + .then((r) => r.data) + +// ── Model Providers ──────────────────────────────────────────────────────────── +export const listProviders = () => + apiClient.get>('/providers').then((r) => r.data) + +export const createProvider = (data: Partial) => + apiClient.post>('/providers', data).then((r) => r.data.data) + +export const updateProvider = (id: string, data: Partial) => + apiClient.patch>(`/providers/${id}`, data).then((r) => r.data.data) + +export const deleteProvider = (id: string) => + apiClient.delete>(`/providers/${id}`).then((r) => r.data) + +// ── Agents ───────────────────────────────────────────────────────────────────── +export const listAgents = (params?: { enabled?: boolean }) => + apiClient.get>('/agents', { params }).then((r) => r.data) + +export const createAgent = (data: Partial) => + apiClient.post>('/agents', data).then((r) => r.data.data) + +export const updateAgent = (id: string, data: Partial) => + apiClient.patch>(`/agents/${id}`, data).then((r) => r.data.data) + +export const deleteAgent = (id: string) => + apiClient.delete>(`/agents/${id}`).then((r) => r.data) + +// ── Browser bindings ─────────────────────────────────────────────────────────── +export const listBrowserBindings = () => + apiClient.get>('/browsers/bindings').then((r) => r.data) + +export const createBrowserBinding = (data: { browser_endpoint: string; site: string; notes?: string }) => + apiClient.post>('/browsers/bindings', data).then((r) => r.data.data) + +export const deleteBrowserBinding = (id: string) => + apiClient.delete>(`/browsers/bindings/${id}`).then((r) => r.data) + +export const addChromeInstance = (count = 1, mode: 'bridge' | 'cdp' = 'bridge', agent_url = '', agent_protocol: 'http' | 'ws' | '' = '') => { + const params = new URLSearchParams({ count: String(count), mode }) + if (agent_url) params.set('agent_url', agent_url) + if (agent_protocol) params.set('agent_protocol', agent_protocol) + return apiClient.post>(`/browsers/chrome-instances?${params}`).then((r) => r.data.data) +} + +export const updateChromeInstanceConfig = (endpoint: string, data: { mode?: string; agent_url?: string | null; agent_protocol?: string | null }) => { + const b64 = btoa(endpoint).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + return apiClient.patch>(`/browsers/instances/${b64}`, data).then((r) => r.data.data) +} + +export const removeChromeInstance = (n: number) => + apiClient.delete>(`/browsers/chrome-instances/${n}`).then((r) => r.data) + +export const restartApi = () => + apiClient.post>('/browsers/restart-api').then((r) => r.data) + +// ── System ───────────────────────────────────────────────────────────────────── export const getHealth = () => - apiClient.get<{ status: string; version: string; task_executor: string }>('/health').then((r) => r.data) - -export const getSystemConfig = () => - apiClient.get>('/system/config').then((r) => r.data.data) - -export const updateSystemConfig = (data: Partial) => - apiClient.patch>('/system/config', data).then((r) => r.data.data) - -export const getWsAgentStatus = () => - apiClient.get>('/browsers/agents/ws-status').then((r) => r.data.data) - -// ── Workers ──────────────────────────────────────────────────────────────────── -export const listWorkers = () => - apiClient.get>('/workers').then((r) => r.data) - -export const getCeleryStats = () => - apiClient.get>>('/workers/celery-stats').then((r) => r.data.data) - -// ── Edge Nodes ───────────────────────────────────────────────────────────────── -export const listNodes = () => - apiClient.get>('/nodes').then((r) => r.data) - -export const getNodeEvents = (id: string) => - apiClient.get>(`/nodes/${id}/events`).then((r) => r.data) - -export const getNodeStats = (id: string, params?: { range?: string; start?: string; end?: string }) => - apiClient.get>(`/nodes/${id}/stats`, { params }).then((r) => r.data.data) - -export const deleteNode = (id: string) => - apiClient.delete>(`/nodes/${id}`).then((r) => r.data) - -export const getInstallScriptUrl = (base: string) => - `${base}/api/v1/nodes/install/agent.sh` - -export const getChromePool = () => - apiClient - .get>('/workers/chrome-pool') - .then((r) => r.data.data) - -export const updateChromeEndpointMode = (endpoint: string, mode: 'bridge' | 'cdp') => { - const b64 = btoa(endpoint).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') - return apiClient - .patch>(`/workers/chrome-pool/${b64}/mode`, { mode }) - .then((r) => r.data.data) -} + rootClient.get<{ status: string; version: string; task_executor: string }>('/health').then((r) => r.data) + +export const getSystemConfig = () => + apiClient.get>('/system/config').then((r) => r.data.data) + +export const updateSystemConfig = (data: Partial) => + apiClient.patch>('/system/config', data).then((r) => r.data.data) + +export const getWsAgentStatus = () => + apiClient.get>('/browsers/agents/ws-status').then((r) => r.data.data) + +// ── Workers ──────────────────────────────────────────────────────────────────── +export const listWorkers = () => + apiClient.get>('/workers').then((r) => r.data) + +export const getCeleryStats = () => + apiClient.get>>('/workers/celery-stats').then((r) => r.data.data) + +// ── Edge Nodes ───────────────────────────────────────────────────────────────── +export const listNodes = () => + apiClient.get>('/nodes').then((r) => r.data) + +export const getNodeEvents = (id: string) => + apiClient.get>(`/nodes/${id}/events`).then((r) => r.data) + +export const getNodeStats = (id: string, params?: { range?: string; start?: string; end?: string }) => + apiClient.get>(`/nodes/${id}/stats`, { params }).then((r) => r.data.data) + +export const deleteNode = (id: string) => + apiClient.delete>(`/nodes/${id}`).then((r) => r.data) + +export const getInstallScriptUrl = (base: string) => + `${base}/api/v1/nodes/install/agent.sh` + +export const getChromePool = () => + apiClient + .get>('/workers/chrome-pool') + .then((r) => r.data.data) + +export const updateChromeEndpointMode = (endpoint: string, mode: 'bridge' | 'cdp') => { + const b64 = btoa(endpoint).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + return apiClient + .patch>(`/workers/chrome-pool/${b64}/mode`, { mode }) + .then((r) => r.data.data) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 937e7963..29a0e8f6 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -141,6 +141,9 @@ export interface NotificationLog { status: string response_data?: Record error_message?: string + ack_status: string + ack_data?: Record + acked_at?: string created_at: string } diff --git a/frontend/src/components/AgentFlightBoard.tsx b/frontend/src/components/AgentFlightBoard.tsx new file mode 100644 index 00000000..0e907f3a --- /dev/null +++ b/frontend/src/components/AgentFlightBoard.tsx @@ -0,0 +1,590 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { formatInTimeZone } from 'date-fns-tz' +import { + AlertTriangle, + Bell, + Bot, + CheckCircle, + CircleDollarSign, + CircleDot, + Cpu, + Database, + MessageSquare, + Timer, + Wrench, + type LucideIcon, +} from 'lucide-react' +import { listRunEvents } from '../api/endpoints' +import type { DashboardStats, TaskRunEvent } from '../api/types' +import Card from './Card' +import StatusBadge from './StatusBadge' +import { MetricTile, PanelHeader, PlaybackControls } from './opencli' + +type RecentRun = DashboardStats['recent_runs'][number] +type FlightKind = 'user' | 'agent' | 'model' | 'tool' | 'store' | 'notify' | 'output' +type FlightStatus = 'done' | 'running' | 'failed' | 'queued' + +interface FlightStep { + id: string + role: string + title: string + message: string + kind: FlightKind + status: FlightStatus + elapsedMs?: number + tokens?: number + costUsd?: number + detail?: Record +} + +const KIND_META: Record = { + user: { + icon: MessageSquare, + accent: 'text-zinc-100', + chip: 'border-zinc-300/35 bg-zinc-300/10 text-zinc-100', + rail: 'from-zinc-300/60 to-white/10', + }, + agent: { + icon: Bot, + accent: 'text-emerald-200', + chip: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200', + rail: 'from-emerald-400/60 to-white/10', + }, + model: { + icon: Cpu, + accent: 'text-primary-100', + chip: 'border-primary-500/45 bg-primary-500/12 text-primary-100', + rail: 'from-primary-500/70 to-white/10', + }, + tool: { + icon: Wrench, + accent: 'text-amber-200', + chip: 'border-amber-400/40 bg-amber-400/10 text-amber-200', + rail: 'from-amber-400/65 to-white/10', + }, + store: { + icon: Database, + accent: 'text-sky-200', + chip: 'border-sky-400/35 bg-sky-400/10 text-sky-200', + rail: 'from-sky-400/60 to-white/10', + }, + notify: { + icon: Bell, + accent: 'text-violet-200', + chip: 'border-violet-400/35 bg-violet-400/10 text-violet-200', + rail: 'from-violet-400/60 to-white/10', + }, + output: { + icon: CheckCircle, + accent: 'text-emerald-200', + chip: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200', + rail: 'from-emerald-400/60 to-white/10', + }, +} + +const STATUS_RING: Record = { + done: 'border-white/14 bg-white/[0.045]', + running: 'border-zinc-100/45 bg-zinc-100/[0.075]', + failed: 'border-signal-red/60 bg-signal-red/[0.12]', + queued: 'border-white/10 bg-black/20 opacity-60', +} + +const TRIGGER_LABELS: Record = { + manual: '手动', + scheduled: '定时', + webhook: 'Webhook', +} + +function normalizeRunStatus(status: string) { + if (status === 'success') return 'completed' + return status +} + +function isRunDone(status: string) { + return ['completed', 'success'].includes(status) +} + +function isRunActive(status: string) { + return ['running', 'pending', 'ai_processing'].includes(status) +} + +function formatDuration(ms?: number) { + if (ms == null) return 'N/A' + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +function formatCost(value?: number) { + if (value == null || Number.isNaN(value)) return 'N/A' + if (value === 0) return '$0' + if (value < 0.01) return `$${value.toFixed(5)}` + return `$${value.toFixed(3)}` +} + +function formatTokens(value?: number) { + if (value == null || Number.isNaN(value)) return 'N/A' + return new Intl.NumberFormat('en-US').format(value) +} + +function metricFromDetail(detail: Record | undefined, keys: string[]) { + if (!detail) return undefined + const queue: unknown[] = [detail] + const wanted = keys.map((key) => key.toLowerCase()) + + while (queue.length > 0) { + const current = queue.shift() + if (!current || typeof current !== 'object') continue + for (const [rawKey, value] of Object.entries(current as Record)) { + const key = rawKey.toLowerCase() + if (wanted.some((item) => key === item || key.endsWith(`_${item}`))) { + const numeric = typeof value === 'number' ? value : Number(value) + if (Number.isFinite(numeric)) return numeric + } + if (value && typeof value === 'object') queue.push(value) + } + } + + return undefined +} + +function stepKind(step: string, message: string): { kind: FlightKind; role: string; title: string } { + const text = `${step} ${message}`.toLowerCase() + if (text.includes('model') || text.includes('ai') || text.includes('llm') || text.includes('processor')) { + return { kind: 'model', role: 'Model Call', title: step || 'AI 处理' } + } + if (text.includes('tool') || text.includes('collect') || text.includes('fetch') || text.includes('scrape')) { + return { kind: 'tool', role: 'Tool', title: step || '工具执行' } + } + if (text.includes('store') || text.includes('record') || text.includes('save') || text.includes('normalize')) { + return { kind: 'store', role: 'Data', title: step || '数据入库' } + } + if (text.includes('notify') || text.includes('webhook') || text.includes('message')) { + return { kind: 'notify', role: 'Notify', title: step || '通知分发' } + } + if (text.includes('finish') || text.includes('complete') || text.includes('done')) { + return { kind: 'output', role: 'Output', title: step || '结果' } + } + return { kind: 'agent', role: 'Agent', title: step || '运行阶段' } +} + +function statusFromEvent(event: TaskRunEvent, index: number, total: number, runStatus: string): FlightStatus { + if (event.level === 'error') return 'failed' + if (isRunActive(runStatus) && index === total - 1) return 'running' + return 'done' +} + +function stepsFromEvents(events: TaskRunEvent[], run: RecentRun): FlightStep[] { + const sorted = [...events].sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at)) + + return sorted.map((event, index) => { + const meta = stepKind(event.step, event.message) + const tokens = metricFromDetail(event.detail, [ + 'total_tokens', + 'tokens', + 'input_tokens', + 'output_tokens', + 'reasoning_tokens', + ]) + const costUsd = metricFromDetail(event.detail, ['cost_usd', 'total_cost_usd', 'usd', 'cost']) + + return { + id: event.id, + role: meta.role, + title: meta.title, + message: event.message, + kind: meta.kind, + status: statusFromEvent(event, index, sorted.length, run.status), + elapsedMs: event.elapsed_ms, + tokens, + costUsd, + detail: event.detail, + } + }) +} + +function fallbackSteps(run: RecentRun): FlightStep[] { + const finalStatus: FlightStatus = run.status === 'failed' + ? 'failed' + : isRunActive(run.status) + ? 'running' + : 'done' + + return [ + { + id: `${run.id}-source`, + role: 'User', + title: '触发任务', + message: TRIGGER_LABELS[run.task_trigger_type] ?? run.task_trigger_type, + kind: 'user', + status: 'done', + }, + { + id: `${run.id}-collect`, + role: 'Tool', + title: '采集源', + message: run.source_name, + kind: 'tool', + status: finalStatus === 'failed' ? 'done' : finalStatus, + }, + { + id: `${run.id}-agent`, + role: 'Agent', + title: '处理数据', + message: `${run.records_collected} 条记录`, + kind: 'agent', + status: finalStatus === 'failed' ? 'done' : finalStatus, + elapsedMs: run.duration_ms, + }, + { + id: `${run.id}-output`, + role: 'Output', + title: run.status === 'failed' ? '运行失败' : '生成结果', + message: run.status === 'failed' ? '等待事件详情' : '记录已进入控制台', + kind: 'output', + status: finalStatus, + elapsedMs: run.duration_ms, + }, + ] +} + +function safeJson(detail?: Record) { + if (!detail || Object.keys(detail).length === 0) return 'N/A' + return JSON.stringify(detail, null, 2) +} + +function RunSelector({ + runs, + selectedId, + onSelect, +}: { + runs: RecentRun[] + selectedId: string | null + onSelect: (id: string) => void +}) { + return ( +
+ {runs.slice(0, 8).map((run) => ( + + ))} +
+ ) +} + +function FlightNode({ + step, + active, + nodeRef, + onSelect, +}: { + step: FlightStep + active: boolean + nodeRef?: (node: HTMLButtonElement | null) => void + onSelect: () => void +}) { + const meta = KIND_META[step.kind] + const Icon = meta.icon + const StatusIcon = step.status === 'failed' ? AlertTriangle : step.status === 'done' ? CheckCircle : CircleDot + + return ( + + ) +} + +function FlightConnector({ active }: { active: boolean }) { + return ( +
+ +
+ ) +} + +export default function AgentFlightBoard({ runs }: { runs: RecentRun[] }) { + const stepRefs = useRef>(new Map()) + const preferredRunId = useMemo(() => { + return runs.find((run) => run.status === 'failed')?.id + ?? runs.find((run) => isRunActive(run.status))?.id + ?? runs[0]?.id + ?? null + }, [runs]) + const [selectedRunId, setSelectedRunId] = useState(preferredRunId) + const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] + + useEffect(() => { + if (!runs.length) return + if (!selectedRunId || !runs.some((run) => run.id === selectedRunId)) { + setSelectedRunId(preferredRunId) + } + }, [preferredRunId, runs, selectedRunId]) + + const { data: events, isFetching } = useQuery({ + queryKey: ['dashboard-run-events', selectedRun?.task_id, selectedRun?.id], + queryFn: () => listRunEvents(selectedRun.task_id, selectedRun.id), + enabled: Boolean(selectedRun), + refetchInterval: isRunActive(selectedRun?.status ?? '') ? 5_000 : false, + }) + + const steps = useMemo(() => { + if (!selectedRun) return [] + if (events?.length) return stepsFromEvents(events, selectedRun) + return fallbackSteps(selectedRun) + }, [events, selectedRun]) + const failedStep = steps.find((step) => step.status === 'failed') + const [activeStepId, setActiveStepId] = useState(null) + const [isPlaying, setIsPlaying] = useState(false) + + useEffect(() => { + const next = failedStep?.id ?? steps[0]?.id ?? null + setActiveStepId((current) => current && steps.some((step) => step.id === current) ? current : next) + }, [failedStep?.id, steps]) + + const activeStep = steps.find((step) => step.id === activeStepId) ?? failedStep ?? steps[0] + const activeStepIndex = activeStep ? steps.findIndex((step) => step.id === activeStep.id) : -1 + const tokenTotal = steps.reduce((sum, step) => sum + (step.tokens ?? 0), 0) + const costTotal = steps.reduce((sum, step) => sum + (step.costUsd ?? 0), 0) + const tokenKnown = steps.some((step) => step.tokens != null) + const costKnown = steps.some((step) => step.costUsd != null) + + useEffect(() => { + setIsPlaying(false) + }, [selectedRun?.id]) + + useEffect(() => { + if (!activeStepId) return + const node = stepRefs.current.get(activeStepId) + if (!node) return + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + node.scrollIntoView({ + behavior: reduceMotion ? 'auto' : 'smooth', + block: 'nearest', + inline: 'center', + }) + }, [activeStepId]) + + useEffect(() => { + if (!isPlaying || steps.length <= 1) return + const timer = window.setInterval(() => { + setActiveStepId((current) => { + const index = Math.max(0, steps.findIndex((step) => step.id === current)) + if (index >= steps.length - 1) { + setIsPlaying(false) + return current + } + return steps[index + 1].id + }) + }, 1800) + + return () => window.clearInterval(timer) + }, [isPlaying, steps]) + + const selectStepAt = (index: number) => { + if (!steps.length) return + const bounded = Math.min(Math.max(index, 0), steps.length - 1) + setActiveStepId(steps[bounded].id) + } + + const handleReset = () => { + setIsPlaying(false) + selectStepAt(0) + } + + const handlePrevious = () => { + setIsPlaying(false) + selectStepAt((activeStepIndex >= 0 ? activeStepIndex : 0) - 1) + } + + const handleNext = () => { + setIsPlaying(false) + selectStepAt((activeStepIndex >= 0 ? activeStepIndex : 0) + 1) + } + + if (!runs.length) { + return ( + + 运行故事板} /> +
暂无运行记录
+
+ ) + } + + return ( + + +

运行故事板

+ {selectedRun && } + + {selectedRun ? formatInTimeZone(new Date(selectedRun.created_at), 'Asia/Shanghai', 'MM-dd HH:mm:ss') : ''} + + + )} + actions={} + /> + +
+ + +
+
+
+

FLOW STRIP

+

运行链路

+
+ 0 ? `${Math.max(activeStepIndex + 1, 1)} / ${steps.length}` : '0 / 0'} + onToggle={() => setIsPlaying((value) => !value)} + onPrevious={handlePrevious} + onNext={handleNext} + onReset={handleReset} + /> +
+ +
+
+ {steps.map((step, index) => ( +
+ { + if (node) stepRefs.current.set(step.id, node) + else stepRefs.current.delete(step.id) + }} + onSelect={() => { + setIsPlaying(false) + setActiveStepId(step.id) + }} + /> + {index < steps.length - 1 && ( + + )} +
+ ))} +
+
+
+ + +
+
+ ) +} diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index c0438d56..d4d5ac5d 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -10,7 +10,7 @@ export default function Card({ children, className, padding = true }: Props) { return (
- - {children} - {h &&

{h}

} -
- ) +function formFieldName(seed: string | undefined, fallback: string) { + const slug = (seed ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return `channel-config-${slug || fallback}` } +function Field({ + label: l, + hint: h, + required, + children, +}: { + label: string + hint?: string + required?: boolean + children: React.ReactNode +}) { + return ( +
+ + {children} + {h &&

{h}

} +
+ ) +} + function TextInput({ value, onChange, placeholder, required, + ariaLabel, }: { value: string onChange: (v: string) => void placeholder?: string required?: boolean + ariaLabel?: string }) { return ( onChange(e.target.value)} - placeholder={placeholder} - required={required} - /> - ) -} - + placeholder={placeholder} + required={required} + /> + ) +} + function NumberInput({ value, onChange, placeholder, min, + ariaLabel, }: { value: number | '' onChange: (v: number | '') => void placeholder?: string min?: number + ariaLabel?: string }) { return ( onChange(e.target.value === '' ? '' : Number(e.target.value))} - placeholder={placeholder} - /> - ) -} - + min={min} + onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} + placeholder={placeholder} + /> + ) +} + function SelectInput({ value, onChange, options, + ariaLabel, }: { value: string onChange: (v: string) => void options: { value: string; label: string }[] + ariaLabel?: string }) { return ( - - ) -} - -// Key-value pair list (for selectors / headers / params / args / defaults) -type KVPair = { key: string; value: string } - -function KVList({ - pairs, - onChange, - keyPlaceholder, - valuePlaceholder, -}: { - pairs: KVPair[] - onChange: (pairs: KVPair[]) => void - keyPlaceholder?: string - valuePlaceholder?: string -}) { - const update = (i: number, field: 'key' | 'value', v: string) => - onChange(pairs.map((p, idx) => (idx === i ? { ...p, [field]: v } : p))) - - const remove = (i: number) => onChange(pairs.filter((_, idx) => idx !== i)) - - return ( -
- {pairs.map((p, i) => ( -
+ + ) +} + +// Key-value pair list (for selectors / headers / params / args / defaults) +type KVPair = { key: string; value: string } + +function KVList({ + pairs, + onChange, + keyPlaceholder, + valuePlaceholder, +}: { + pairs: KVPair[] + onChange: (pairs: KVPair[]) => void + keyPlaceholder?: string + valuePlaceholder?: string +}) { + const update = (i: number, field: 'key' | 'value', v: string) => + onChange(pairs.map((p, idx) => (idx === i ? { ...p, [field]: v } : p))) + + const remove = (i: number) => onChange(pairs.filter((_, idx) => idx !== i)) + + return ( +
+ {pairs.map((p, i) => ( +
update(i, 'key', e.target.value)} - placeholder={keyPlaceholder ?? 'key'} - /> + placeholder={keyPlaceholder ?? 'key'} + /> update(i, 'value', e.target.value)} - placeholder={valuePlaceholder ?? 'value'} - /> + placeholder={valuePlaceholder ?? 'value'} + /> -
- ))} - -
- ) -} - -function kvToObj(pairs: KVPair[]): Record { - return Object.fromEntries(pairs.filter((p) => p.key).map((p) => [p.key, p.value])) -} - -function objToKv(obj: Record | undefined): KVPair[] { - if (!obj) return [] - return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) })) -} - -// ── Per-channel config forms ────────────────────────────────────────────────── - -function RSSConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - return ( -
- - onChange({ ...config, feed_url: v })} - placeholder="https://hnrss.org/frontpage" - required - /> - -
- - onChange({ ...config, max_entries: v === '' ? undefined : v })} - placeholder="50" - min={1} - /> - - - onChange({ ...config, timeout: v === '' ? undefined : v })} - placeholder="30" - min={1} - /> - -
-
- ) -} - -function APIConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - const auth = (config.auth as Record) ?? {} - const authType = auth.type ?? 'none' - const [params, setParams] = useState(objToKv(config.params as Record)) - const [headers, setHeaders] = useState(objToKv(config.headers as Record)) - - const update = (patch: Partial>) => onChange({ ...config, ...patch }) - - const updateParams = (pairs: KVPair[]) => { - setParams(pairs) - update({ params: kvToObj(pairs) }) - } - - const updateHeaders = (pairs: KVPair[]) => { - setHeaders(pairs) - update({ headers: kvToObj(pairs) }) - } - - const updateAuth = (patch: Partial>) => - update({ auth: { ...auth, ...patch } }) - - return ( -
- - update({ base_url: v })} - placeholder="https://api.github.com" - required - /> - -
- - update({ endpoint: v })} - placeholder="/repos/owner/repo/issues" - required - /> - - - update({ method: v })} - options={['GET', 'POST', 'PUT', 'PATCH'].map((m) => ({ value: m, label: m }))} - /> - - - update({ result_path: v })} - placeholder="data.items" - /> - -
- - - update({ auth: { type: v } })} - options={[ - { value: 'none', label: t('channelConfig.authNone') }, - { value: 'bearer', label: t('channelConfig.authBearer') }, - { value: 'basic', label: t('channelConfig.authBasic') }, - { value: 'api_key', label: t('channelConfig.authApiKey') }, - ]} - /> - - - {authType === 'bearer' && ( - - updateAuth({ token_env: v })} - placeholder="GITHUB_TOKEN" - /> - - )} - {authType === 'basic' && ( -
- - updateAuth({ username: v })} - placeholder="{{secret:API_USER}}" - /> - - - updateAuth({ password: v })} - placeholder="{{secret:API_PASS}}" - /> - -
- )} - {authType === 'api_key' && ( -
- - updateAuth({ header: v })} - placeholder="X-API-Key" - /> - - - updateAuth({ key_env: v })} - placeholder="MY_API_KEY" - /> - -
- )} - - - - - - - - - update({ timeout: v === '' ? undefined : v })} - placeholder="30" - min={1} - /> - -
- ) -} - -function WebScraperConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - const [selectors, setSelectors] = useState( - objToKv(config.selectors as Record), - ) - - const update = (patch: Partial>) => onChange({ ...config, ...patch }) - - const updateSelectors = (pairs: KVPair[]) => { - setSelectors(pairs) - update({ selectors: kvToObj(pairs) }) - } - - return ( -
- - update({ url: v })} - placeholder="https://news.ycombinator.com" - required - /> - - - update({ list_selector: v })} - placeholder=".athing" - /> - - - - - - update({ timeout: v === '' ? undefined : v })} - placeholder="30" - min={1} - /> - -
- ) -} - -function CLIConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - const cmdArr = (config.command as string[]) ?? [] - const [cmdStr, setCmdStr] = useState(cmdArr.join(' ')) - const [defaults, setDefaults] = useState( - objToKv(config.defaults as Record), - ) - const [envVars, setEnvVars] = useState( - objToKv(config.env as Record), - ) - - const update = (patch: Partial>) => onChange({ ...config, ...patch }) - - const updateCmd = (v: string) => { - setCmdStr(v) - // Split respecting quoted strings - const parts = v.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] - update({ command: parts }) - } - - const updateDefaults = (pairs: KVPair[]) => { - setDefaults(pairs) - update({ defaults: kvToObj(pairs) }) - } - - const updateEnv = (pairs: KVPair[]) => { - setEnvVars(pairs) - update({ env: kvToObj(pairs) }) - } - - return ( -
- - update({ binary: v })} - placeholder="curl" - required - /> - - - - - - update({ output_format: v })} - options={[ - { value: 'json', label: t('channelConfig.outputJson') }, - { value: 'text', label: t('channelConfig.outputText') }, - ]} - /> - - - - - - - - - update({ timeout: v === '' ? undefined : v })} - placeholder="60" - min={1} - /> - -
- ) -} - -// ── OpenCLI presets ────────────────────────────────────────────────────────── - -type Preset = { - label: string - group: string - site: string - command: string - args: Record - /** Placeholder/description shown for each arg value input */ - argHints?: Record -} - -const OPENCLI_PRESETS: Preset[] = [ - // ── 国内 (Chinese, login required) ─────────────────────────────────────── - // Fields: rank, title, author, likes, url - { group: '🇨🇳 国内', label: '小红书 · 搜索', site: 'xiaohongshu', command: 'search', - args: { keyword: '', limit: '20' }, - argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, - // Fields: id, title, type, likes, url - { group: '🇨🇳 国内', label: '小红书 · 用户笔记', site: 'xiaohongshu', command: 'user', - args: { id: '', limit: '20' }, - argHints: { id: '用户 ID(从主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, - // Fields: rank, title, author, play, danmaku - { group: '🇨🇳 国内', label: 'Bilibili · 热门视频', site: 'bilibili', command: 'hot', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, author, score, url - { group: '🇨🇳 国内', label: 'Bilibili · 排行榜', site: 'bilibili', command: 'ranking', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: id, author, text, likes, url - { group: '🇨🇳 国内', label: 'Bilibili · 关注动态', site: 'bilibili', command: 'dynamic', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, author, plays, url - { group: '🇨🇳 国内', label: 'Bilibili · 收藏夹', site: 'bilibili', command: 'favorite', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, plays, likes, date, url - { group: '🇨🇳 国内', label: 'Bilibili · 用户视频', site: 'bilibili', command: 'user-videos', - args: { uid: '', limit: '20' }, - argHints: { uid: 'UP 主 UID(从个人主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, - // Fields: rank, title, heat, answers, url - { group: '🇨🇳 国内', label: '知乎 · 热榜', site: 'zhihu', command: 'hot', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, author, votes, content - { group: '🇨🇳 国内', label: '知乎 · 问题回答', site: 'zhihu', command: 'question', - args: { id: '', limit: '10' }, - argHints: { id: '问题 ID(从 URL 中获取,如 /question/123456789)', limit: '返回答案数(默认 10)' } }, - // Fields: rank, word(→title), hot_value, category, label, url - { group: '🇨🇳 国内', label: '微博 · 热搜', site: 'weibo', command: 'hot', - args: {}, - argHints: {} }, - // Fields: rank, title, score, author, url - { group: '🇨🇳 国内', label: 'V2EX · 热门话题', site: 'v2ex', command: 'hot', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, score, author, url - { group: '🇨🇳 国内', label: 'V2EX · 最新话题', site: 'v2ex', command: 'latest', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, author, text(→content), likes, url - { group: '🇨🇳 国内', label: '雪球 · 动态', site: 'xueqiu', command: 'hot', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, symbol, name(→title), price, changePercent, heat - { group: '🇨🇳 国内', label: '雪球 · 热门股票', site: 'xueqiu', command: 'hot-stock', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20,最大 50)' } }, - // Fields: name(→title), symbol, price, changePercent, marketCap - { group: '🇨🇳 国内', label: '雪球 · 股票行情', site: 'xueqiu', command: 'stock', - args: { symbol: '601318' }, - argHints: { symbol: 'A 股代码(如 601318 中国平安)或港股(如 00700 腾讯)' } }, - // Fields: rank, title, price, mall, comments, url - { group: '🇨🇳 国内', label: '什么值得买 · 搜索', site: 'smzdm', command: 'search', - args: { keyword: '', limit: '20' }, - argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, - // Fields: name(→title), salary, company, area, experience, degree, skills, boss, url - { group: '🇨🇳 国内', label: 'Boss直聘 · 职位搜索', site: 'boss', command: 'search', - args: { keyword: '', city: '101010100', limit: '20' }, - argHints: { keyword: '职位名称或关键词(必填,如 "前端工程师")', city: '城市代码(101010100=北京,101020100=上海,101280100=广州,101280600=深圳)', limit: '返回条数(默认 20)' } }, - // Fields: rank, name(→title), type, score, price, url - { group: '🇨🇳 国内', label: '携程 · 目的地搜索', site: 'ctrip', command: 'search', - args: { query: '', limit: '15' }, - argHints: { query: '目的地或景点名称(必填,如 "三亚")', limit: '返回条数(默认 15)' } }, - // Fields: title, author, description(→content), subscribers, episodes, updated - { group: '🇨🇳 国内', label: '小宇宙 · 播客信息', site: 'xiaoyuzhou', command: 'podcast', - args: { id: '' }, - argHints: { id: '播客 ID(从 URL 获取,如 5e280fbd418a84a0463d3e3b)' } }, - // Fields: eid, title, duration, plays, date - { group: '🇨🇳 国内', label: '小宇宙 · 单集列表', site: 'xiaoyuzhou', command: 'podcast-episodes', - args: { id: '', limit: '15' }, - argHints: { id: '播客 ID(同上)', limit: '返回集数(最多 15,受 SSR 限制)' } }, - - // ── Public (no login required) ──────────────────────────────────────────── - // Fields: rank, title, score, author, comments, url - { group: '🌐 Public', label: 'Hacker News · top stories', site: 'hackernews', command: 'top', - args: { limit: '20' }, - argHints: { limit: '返回条数(1–500)' } }, - // Fields: rank, title, description, url - { group: '🌐 Public', label: 'BBC · latest news', site: 'bbc', command: 'news', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, date, section, url - { group: '🌐 Public', label: 'Reuters · search', site: 'reuters', command: 'search', - args: { query: 'technology', limit: '20' }, - argHints: { query: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, - - // ── Global (login required) ─────────────────────────────────────────────── - // Fields: rank, topic(→title), tweets - { group: '🌍 Global', label: 'Twitter/X · trending', site: 'twitter', command: 'trending', - args: {}, - argHints: {} }, - // Fields: id, author, text(→content), likes, retweets, replies, views, created_at, url - { group: '🌍 Global', label: 'Twitter/X · timeline', site: 'twitter', command: 'timeline', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: id, author, text(→content), likes, views, url - { group: '🌍 Global', label: 'Twitter/X · search', site: 'twitter', command: 'search', - args: { query: '', limit: '20' }, - argHints: { query: '搜索关键词,支持运算符(必填,如 "AI lang:en")', limit: '返回条数(默认 20)' } }, - // Fields: title, subreddit, score, comments, url - { group: '🌍 Global', label: 'Twitter/X · bookmarks', site: 'twitter', command: 'bookmarks', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: title, subreddit, author, upvotes, comments, url - { group: '🌍 Global', label: 'Reddit · frontpage', site: 'reddit', command: 'frontpage', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, subreddit, score, comments, url - { group: '🌍 Global', label: 'Reddit · hot', site: 'reddit', command: 'hot', - args: { limit: '20' }, - argHints: { subreddit: '子版块名称(可选,留空则为全站热门,如 "programming")', limit: '返回条数(默认 20)' } }, - // Fields: title, subreddit, score, comments, url - { group: '🌍 Global', label: 'Reddit · saved posts', site: 'reddit', command: 'saved', - args: { limit: '20' }, - argHints: { limit: '返回条数(默认 20)' } }, - // Fields: rank, title, channel(→author), views, duration, url - { group: '🌍 Global', label: 'YouTube · search', site: 'youtube', command: 'search', - args: { query: 'technology', limit: '10' }, - argHints: { query: '搜索关键词(必填)', limit: '返回条数(最多 10)' } }, - // Fields: rank, title, company, location, listed(→published_at), salary, url - { group: '🌍 Global', label: 'LinkedIn · job search', site: 'linkedin', command: 'search', - args: { query: 'AI engineer', limit: '20' }, - argHints: { query: '职位名称或关键词(必填)', limit: '返回条数(默认 20)' } }, - // Fields: symbol, name(→title), price, change, changePercent, open, high, low, volume, marketCap - { group: '🌍 Global', label: 'Yahoo Finance · quote', site: 'yahoo-finance', command: 'quote', - args: { symbol: 'AAPL' }, - argHints: { symbol: '股票代码(如 AAPL、GOOGL、TSLA、SPY)' } }, - // Fields: symbol, name(→title), price, change, changePct, peRatio, eps, marketCap - { group: '🌍 Global', label: 'Barchart · stock quote', site: 'barchart', command: 'quote', - args: { symbol: 'AAPL' }, - argHints: { symbol: '股票代码(如 AAPL、SPY、QQQ)' } }, -] - -const PRESET_DEFAULT = OPENCLI_PRESETS[0] - -// ── Derived lookup structures ───────────────────────────────────────────────── - -const SITE_LABELS: Record = { - xiaohongshu: '小红书', bilibili: 'Bilibili', zhihu: '知乎', - weibo: '微博', v2ex: 'V2EX', xueqiu: '雪球', - smzdm: '什么值得买', boss: 'Boss直聘', ctrip: '携程', xiaoyuzhou: '小宇宙', - hackernews: 'Hacker News', bbc: 'BBC', reuters: 'Reuters', - twitter: 'Twitter/X', reddit: 'Reddit', youtube: 'YouTube', - linkedin: 'LinkedIn', 'yahoo-finance': 'Yahoo Finance', barchart: 'Barchart', -} - -// site → ordered list of presets -const COMMANDS_BY_SITE: Record = {} -for (const p of OPENCLI_PRESETS) { - if (!COMMANDS_BY_SITE[p.site]) COMMANDS_BY_SITE[p.site] = [] - COMMANDS_BY_SITE[p.site].push(p) -} - -// Groups for the site — order matches preset group order -const SITE_GROUPS = [ - { label: '🇨🇳 国内', sites: ['xiaohongshu','bilibili','zhihu','weibo','v2ex','xueqiu','smzdm','boss','ctrip','xiaoyuzhou'] }, - { label: '🌐 Public', sites: ['hackernews','bbc','reuters'] }, - { label: '🌍 Global', sites: ['twitter','reddit','youtube','linkedin','yahoo-finance','barchart'] }, -] - -// Args list with per-key hint text and dropdown for adding known parameters -function ArgsKVList({ - pairs, - onChange, - hints, -}: { - pairs: KVPair[] - onChange: (pairs: KVPair[]) => void - hints?: Record -}) { - const update = (i: number, field: 'key' | 'value', v: string) => - onChange(pairs.map((p, idx) => (idx === i ? { ...p, [field]: v } : p))) - const remove = (i: number) => onChange(pairs.filter((_, idx) => idx !== i)) - - // Hint keys not yet added — shown as dropdown options - const usedKeys = new Set(pairs.map((p) => p.key)) - const availableKeys = hints ? Object.keys(hints).filter((k) => !usedKeys.has(k)) : [] - - const addParam = (key: string) => { - if (key === '__custom__') { - onChange([...pairs, { key: '', value: '' }]) - } else { - onChange([...pairs, { key, value: '' }]) - } - } - - return ( -
- {pairs.map((p, i) => { - const hintText = hints?.[p.key] - return ( -
-
+ > + + +
+ ))} + +
+ ) +} + +function kvToObj(pairs: KVPair[]): Record { + return Object.fromEntries(pairs.filter((p) => p.key).map((p) => [p.key, p.value])) +} + +function objToKv(obj: Record | undefined): KVPair[] { + if (!obj) return [] + return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) })) +} + +// ── Per-channel config forms ────────────────────────────────────────────────── + +function RSSConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + return ( +
+ + onChange({ ...config, feed_url: v })} + placeholder="https://hnrss.org/frontpage" + required + /> + +
+ + onChange({ ...config, max_entries: v === '' ? undefined : v })} + placeholder="50" + min={1} + /> + + + onChange({ ...config, timeout: v === '' ? undefined : v })} + placeholder="30" + min={1} + /> + +
+
+ ) +} + +function APIConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + const auth = (config.auth as Record) ?? {} + const authType = auth.type ?? 'none' + const [params, setParams] = useState(objToKv(config.params as Record)) + const [headers, setHeaders] = useState(objToKv(config.headers as Record)) + + const update = (patch: Partial>) => onChange({ ...config, ...patch }) + + const updateParams = (pairs: KVPair[]) => { + setParams(pairs) + update({ params: kvToObj(pairs) }) + } + + const updateHeaders = (pairs: KVPair[]) => { + setHeaders(pairs) + update({ headers: kvToObj(pairs) }) + } + + const updateAuth = (patch: Partial>) => + update({ auth: { ...auth, ...patch } }) + + return ( +
+ + update({ base_url: v })} + placeholder="https://api.github.com" + required + /> + +
+ + update({ endpoint: v })} + placeholder="/repos/owner/repo/issues" + required + /> + + + update({ method: v })} + options={['GET', 'POST', 'PUT', 'PATCH'].map((m) => ({ value: m, label: m }))} + /> + + + update({ result_path: v })} + placeholder="data.items" + /> + +
+ + + update({ auth: { type: v } })} + options={[ + { value: 'none', label: t('channelConfig.authNone') }, + { value: 'bearer', label: t('channelConfig.authBearer') }, + { value: 'basic', label: t('channelConfig.authBasic') }, + { value: 'api_key', label: t('channelConfig.authApiKey') }, + ]} + /> + + + {authType === 'bearer' && ( + + updateAuth({ token_env: v })} + placeholder="GITHUB_TOKEN" + /> + + )} + {authType === 'basic' && ( +
+ + updateAuth({ username: v })} + placeholder="{{secret:API_USER}}" + /> + + + updateAuth({ password: v })} + placeholder="{{secret:API_PASS}}" + /> + +
+ )} + {authType === 'api_key' && ( +
+ + updateAuth({ header: v })} + placeholder="X-API-Key" + /> + + + updateAuth({ key_env: v })} + placeholder="MY_API_KEY" + /> + +
+ )} + + + + + + + + + update({ timeout: v === '' ? undefined : v })} + placeholder="30" + min={1} + /> + +
+ ) +} + +function WebScraperConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + const [selectors, setSelectors] = useState( + objToKv(config.selectors as Record), + ) + + const update = (patch: Partial>) => onChange({ ...config, ...patch }) + + const updateSelectors = (pairs: KVPair[]) => { + setSelectors(pairs) + update({ selectors: kvToObj(pairs) }) + } + + return ( +
+ + update({ url: v })} + placeholder="https://news.ycombinator.com" + required + /> + + + update({ list_selector: v })} + placeholder=".athing" + /> + + + + + + update({ timeout: v === '' ? undefined : v })} + placeholder="30" + min={1} + /> + +
+ ) +} + +function CLIConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + const cmdArr = (config.command as string[]) ?? [] + const [cmdStr, setCmdStr] = useState(cmdArr.join(' ')) + const [defaults, setDefaults] = useState( + objToKv(config.defaults as Record), + ) + const [envVars, setEnvVars] = useState( + objToKv(config.env as Record), + ) + + const update = (patch: Partial>) => onChange({ ...config, ...patch }) + + const updateCmd = (v: string) => { + setCmdStr(v) + // Split respecting quoted strings + const parts = v.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] + update({ command: parts }) + } + + const updateDefaults = (pairs: KVPair[]) => { + setDefaults(pairs) + update({ defaults: kvToObj(pairs) }) + } + + const updateEnv = (pairs: KVPair[]) => { + setEnvVars(pairs) + update({ env: kvToObj(pairs) }) + } + + return ( +
+ + update({ binary: v })} + placeholder="curl" + required + /> + + + + + + update({ output_format: v })} + options={[ + { value: 'json', label: t('channelConfig.outputJson') }, + { value: 'text', label: t('channelConfig.outputText') }, + ]} + /> + + + + + + + + + update({ timeout: v === '' ? undefined : v })} + placeholder="60" + min={1} + /> + +
+ ) +} + +// ── OpenCLI presets ────────────────────────────────────────────────────────── + +type Preset = { + label: string + group: string + site: string + command: string + args: Record + /** Placeholder/description shown for each arg value input */ + argHints?: Record +} + +const OPENCLI_PRESETS: Preset[] = [ + // ── 国内 (Chinese, login required) ─────────────────────────────────────── + // Fields: rank, title, author, likes, url + { group: '🇨🇳 国内', label: '小红书 · 搜索', site: 'xiaohongshu', command: 'search', + args: { keyword: '', limit: '20' }, + argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, + // Fields: id, title, type, likes, url + { group: '🇨🇳 国内', label: '小红书 · 用户笔记', site: 'xiaohongshu', command: 'user', + args: { id: '', limit: '20' }, + argHints: { id: '用户 ID(从主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, + // Fields: rank, title, author, play, danmaku + { group: '🇨🇳 国内', label: 'Bilibili · 热门视频', site: 'bilibili', command: 'hot', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, author, score, url + { group: '🇨🇳 国内', label: 'Bilibili · 排行榜', site: 'bilibili', command: 'ranking', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: id, author, text, likes, url + { group: '🇨🇳 国内', label: 'Bilibili · 关注动态', site: 'bilibili', command: 'dynamic', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, author, plays, url + { group: '🇨🇳 国内', label: 'Bilibili · 收藏夹', site: 'bilibili', command: 'favorite', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, plays, likes, date, url + { group: '🇨🇳 国内', label: 'Bilibili · 用户视频', site: 'bilibili', command: 'user-videos', + args: { uid: '', limit: '20' }, + argHints: { uid: 'UP 主 UID(从个人主页 URL 获取,必填)', limit: '返回条数(默认 20)' } }, + // Fields: rank, title, heat, answers, url + { group: '🇨🇳 国内', label: '知乎 · 热榜', site: 'zhihu', command: 'hot', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, author, votes, content + { group: '🇨🇳 国内', label: '知乎 · 问题回答', site: 'zhihu', command: 'question', + args: { id: '', limit: '10' }, + argHints: { id: '问题 ID(从 URL 中获取,如 /question/123456789)', limit: '返回答案数(默认 10)' } }, + // Fields: rank, word(→title), hot_value, category, label, url + { group: '🇨🇳 国内', label: '微博 · 热搜', site: 'weibo', command: 'hot', + args: {}, + argHints: {} }, + // Fields: rank, title, score, author, url + { group: '🇨🇳 国内', label: 'V2EX · 热门话题', site: 'v2ex', command: 'hot', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, score, author, url + { group: '🇨🇳 国内', label: 'V2EX · 最新话题', site: 'v2ex', command: 'latest', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, author, text(→content), likes, url + { group: '🇨🇳 国内', label: '雪球 · 动态', site: 'xueqiu', command: 'hot', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, symbol, name(→title), price, changePercent, heat + { group: '🇨🇳 国内', label: '雪球 · 热门股票', site: 'xueqiu', command: 'hot-stock', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20,最大 50)' } }, + // Fields: name(→title), symbol, price, changePercent, marketCap + { group: '🇨🇳 国内', label: '雪球 · 股票行情', site: 'xueqiu', command: 'stock', + args: { symbol: '601318' }, + argHints: { symbol: 'A 股代码(如 601318 中国平安)或港股(如 00700 腾讯)' } }, + // Fields: rank, title, price, mall, comments, url + { group: '🇨🇳 国内', label: '什么值得买 · 搜索', site: 'smzdm', command: 'search', + args: { keyword: '', limit: '20' }, + argHints: { keyword: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, + // Fields: name(→title), salary, company, area, experience, degree, skills, boss, url + { group: '🇨🇳 国内', label: 'Boss直聘 · 职位搜索', site: 'boss', command: 'search', + args: { keyword: '', city: '101010100', limit: '20' }, + argHints: { keyword: '职位名称或关键词(必填,如 "前端工程师")', city: '城市代码(101010100=北京,101020100=上海,101280100=广州,101280600=深圳)', limit: '返回条数(默认 20)' } }, + // Fields: rank, name(→title), type, score, price, url + { group: '🇨🇳 国内', label: '携程 · 目的地搜索', site: 'ctrip', command: 'search', + args: { query: '', limit: '15' }, + argHints: { query: '目的地或景点名称(必填,如 "三亚")', limit: '返回条数(默认 15)' } }, + // Fields: title, author, description(→content), subscribers, episodes, updated + { group: '🇨🇳 国内', label: '小宇宙 · 播客信息', site: 'xiaoyuzhou', command: 'podcast', + args: { id: '' }, + argHints: { id: '播客 ID(从 URL 获取,如 5e280fbd418a84a0463d3e3b)' } }, + // Fields: eid, title, duration, plays, date + { group: '🇨🇳 国内', label: '小宇宙 · 单集列表', site: 'xiaoyuzhou', command: 'podcast-episodes', + args: { id: '', limit: '15' }, + argHints: { id: '播客 ID(同上)', limit: '返回集数(最多 15,受 SSR 限制)' } }, + + // ── Public (no login required) ──────────────────────────────────────────── + // Fields: rank, title, score, author, comments, url + { group: '🌐 Public', label: 'Hacker News · top stories', site: 'hackernews', command: 'top', + args: { limit: '20' }, + argHints: { limit: '返回条数(1–500)' } }, + // Fields: rank, title, description, url + { group: '🌐 Public', label: 'BBC · latest news', site: 'bbc', command: 'news', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, date, section, url + { group: '🌐 Public', label: 'Reuters · search', site: 'reuters', command: 'search', + args: { query: 'technology', limit: '20' }, + argHints: { query: '搜索关键词(必填)', limit: '返回条数(默认 20)' } }, + + // ── Global (login required) ─────────────────────────────────────────────── + // Fields: rank, topic(→title), tweets + { group: '🌍 Global', label: 'Twitter/X · trending', site: 'twitter', command: 'trending', + args: {}, + argHints: {} }, + // Fields: id, author, text(→content), likes, retweets, replies, views, created_at, url + { group: '🌍 Global', label: 'Twitter/X · timeline', site: 'twitter', command: 'timeline', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: id, author, text(→content), likes, views, url + { group: '🌍 Global', label: 'Twitter/X · search', site: 'twitter', command: 'search', + args: { query: '', limit: '20' }, + argHints: { query: '搜索关键词,支持运算符(必填,如 "AI lang:en")', limit: '返回条数(默认 20)' } }, + // Fields: title, subreddit, score, comments, url + { group: '🌍 Global', label: 'Twitter/X · bookmarks', site: 'twitter', command: 'bookmarks', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: title, subreddit, author, upvotes, comments, url + { group: '🌍 Global', label: 'Reddit · frontpage', site: 'reddit', command: 'frontpage', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, subreddit, score, comments, url + { group: '🌍 Global', label: 'Reddit · hot', site: 'reddit', command: 'hot', + args: { limit: '20' }, + argHints: { subreddit: '子版块名称(可选,留空则为全站热门,如 "programming")', limit: '返回条数(默认 20)' } }, + // Fields: title, subreddit, score, comments, url + { group: '🌍 Global', label: 'Reddit · saved posts', site: 'reddit', command: 'saved', + args: { limit: '20' }, + argHints: { limit: '返回条数(默认 20)' } }, + // Fields: rank, title, channel(→author), views, duration, url + { group: '🌍 Global', label: 'YouTube · search', site: 'youtube', command: 'search', + args: { query: 'technology', limit: '10' }, + argHints: { query: '搜索关键词(必填)', limit: '返回条数(最多 10)' } }, + // Fields: rank, title, company, location, listed(→published_at), salary, url + { group: '🌍 Global', label: 'LinkedIn · job search', site: 'linkedin', command: 'search', + args: { query: 'AI engineer', limit: '20' }, + argHints: { query: '职位名称或关键词(必填)', limit: '返回条数(默认 20)' } }, + // Fields: symbol, name(→title), price, change, changePercent, open, high, low, volume, marketCap + { group: '🌍 Global', label: 'Yahoo Finance · quote', site: 'yahoo-finance', command: 'quote', + args: { symbol: 'AAPL' }, + argHints: { symbol: '股票代码(如 AAPL、GOOGL、TSLA、SPY)' } }, + // Fields: symbol, name(→title), price, change, changePct, peRatio, eps, marketCap + { group: '🌍 Global', label: 'Barchart · stock quote', site: 'barchart', command: 'quote', + args: { symbol: 'AAPL' }, + argHints: { symbol: '股票代码(如 AAPL、SPY、QQQ)' } }, +] + +const PRESET_DEFAULT = OPENCLI_PRESETS[0] + +// ── Derived lookup structures ───────────────────────────────────────────────── + +const SITE_LABELS: Record = { + xiaohongshu: '小红书', bilibili: 'Bilibili', zhihu: '知乎', + weibo: '微博', v2ex: 'V2EX', xueqiu: '雪球', + smzdm: '什么值得买', boss: 'Boss直聘', ctrip: '携程', xiaoyuzhou: '小宇宙', + hackernews: 'Hacker News', bbc: 'BBC', reuters: 'Reuters', + twitter: 'Twitter/X', reddit: 'Reddit', youtube: 'YouTube', + linkedin: 'LinkedIn', 'yahoo-finance': 'Yahoo Finance', barchart: 'Barchart', +} + +// site → ordered list of presets +const COMMANDS_BY_SITE: Record = {} +for (const p of OPENCLI_PRESETS) { + if (!COMMANDS_BY_SITE[p.site]) COMMANDS_BY_SITE[p.site] = [] + COMMANDS_BY_SITE[p.site].push(p) +} + +// Groups for the site — order matches preset group order +const SITE_GROUPS = [ + { label: '🇨🇳 国内', sites: ['xiaohongshu','bilibili','zhihu','weibo','v2ex','xueqiu','smzdm','boss','ctrip','xiaoyuzhou'] }, + { label: '🌐 Public', sites: ['hackernews','bbc','reuters'] }, + { label: '🌍 Global', sites: ['twitter','reddit','youtube','linkedin','yahoo-finance','barchart'] }, +] + +// Args list with per-key hint text and dropdown for adding known parameters +function ArgsKVList({ + pairs, + onChange, + hints, +}: { + pairs: KVPair[] + onChange: (pairs: KVPair[]) => void + hints?: Record +}) { + const update = (i: number, field: 'key' | 'value', v: string) => + onChange(pairs.map((p, idx) => (idx === i ? { ...p, [field]: v } : p))) + const remove = (i: number) => onChange(pairs.filter((_, idx) => idx !== i)) + + // Hint keys not yet added — shown as dropdown options + const usedKeys = new Set(pairs.map((p) => p.key)) + const availableKeys = hints ? Object.keys(hints).filter((k) => !usedKeys.has(k)) : [] + + const addParam = (key: string) => { + if (key === '__custom__') { + onChange([...pairs, { key: '', value: '' }]) + } else { + onChange([...pairs, { key, value: '' }]) + } + } + + return ( +
+ {pairs.map((p, i) => { + const hintText = hints?.[p.key] + return ( +
+
update(i, 'key', e.target.value)} - placeholder="参数名" - /> + placeholder="参数名" + /> update(i, 'value', e.target.value)} - placeholder={hintText ?? '参数值'} - /> + placeholder={hintText ?? '参数值'} + /> -
- {hintText && ( -

{hintText}

- )} -
- ) - })} - {availableKeys.length > 0 ? ( + > + + +
+ {hintText && ( +

{hintText}

+ )} +
+ ) + })} + {availableKeys.length > 0 ? ( - ) : ( - - )} -
- ) -} - -function OpenCLIConfig({ - config, - onChange, -}: { - config: Record - onChange: (c: Record) => void -}) { - const { t } = useTranslation() - const [args, setArgs] = useState(objToKv(config.args as Record)) - - const currentSite = (config.site as string) ?? '' - const currentCommand = (config.command as string) ?? '' - const siteCommands = COMMANDS_BY_SITE[currentSite] ?? [] - const currentPreset = siteCommands.find((p) => p.command === currentCommand) - - const applyPreset = (preset: Preset) => { - const newPairs = objToKv(preset.args) - setArgs(newPairs) - onChange({ site: preset.site, command: preset.command, args: preset.args, format: config.format ?? 'json' }) - } - - const onSiteChange = (site: string) => { - const cmds = COMMANDS_BY_SITE[site] - if (cmds?.length) { - applyPreset(cmds[0]) - } else { - onChange({ ...config, site, command: '' }) - } - } - - const onCommandChange = (command: string) => { - const preset = siteCommands.find((p) => p.command === command) - if (preset) applyPreset(preset) - } - - const updateArgs = (pairs: KVPair[]) => { - setArgs(pairs) - onChange({ ...config, args: kvToObj(pairs) }) - } - - // Strip site prefix from label for command option text - const commandOptionLabel = (p: Preset) => { - const parts = p.label.split(' · ') - return parts.length > 1 ? parts.slice(1).join(' · ') : p.command - } - - return ( -
-
+ onChange={(e) => { if (e.target.value) addParam(e.target.value) }} + > + + {availableKeys.map((k) => ( + + ))} + + + ) : ( + + )} +
+ ) +} + +function OpenCLIConfig({ + config, + onChange, +}: { + config: Record + onChange: (c: Record) => void +}) { + const { t } = useTranslation() + const [args, setArgs] = useState(objToKv(config.args as Record)) + + const currentSite = (config.site as string) ?? '' + const currentCommand = (config.command as string) ?? '' + const siteCommands = COMMANDS_BY_SITE[currentSite] ?? [] + const currentPreset = siteCommands.find((p) => p.command === currentCommand) + + const applyPreset = (preset: Preset) => { + const newPairs = objToKv(preset.args) + setArgs(newPairs) + onChange({ site: preset.site, command: preset.command, args: preset.args, format: config.format ?? 'json' }) + } + + const onSiteChange = (site: string) => { + const cmds = COMMANDS_BY_SITE[site] + if (cmds?.length) { + applyPreset(cmds[0]) + } else { + onChange({ ...config, site, command: '' }) + } + } + + const onCommandChange = (command: string) => { + const preset = siteCommands.find((p) => p.command === command) + if (preset) applyPreset(preset) + } + + const updateArgs = (pairs: KVPair[]) => { + setArgs(pairs) + onChange({ ...config, args: kvToObj(pairs) }) + } + + // Strip site prefix from label for command option text + const commandOptionLabel = (p: Preset) => { + const parts = p.label.split(' · ') + return parts.length > 1 ? parts.slice(1).join(' · ') : p.command + } + + return ( +
+
- + - -
- - {args.length > 0 && ( - - - - )} - - {args.length === 0 && currentCommand && ( -

{t('channelConfig.noArgs')}

- )} - - - onChange({ ...config, format: v })} - options={[ - { value: 'json', label: 'JSON(推荐)' }, - { value: 'table', label: 'Table' }, - { value: 'yaml', label: 'YAML' }, - { value: 'md', label: 'Markdown' }, - { value: 'csv', label: 'CSV' }, - ]} - /> - - -
- ) -} - -// Standard fields actually populated for each site:command -// (title/url/content/author/published_at — source_id is always injected by pipeline) -export const SITE_STANDARD_FIELDS: Record = { - 'xiaohongshu:search': ['title', 'author', 'url'], - 'xiaohongshu:user': ['title', 'url'], - 'bilibili:hot': ['title', 'author'], - 'bilibili:ranking': ['title', 'author', 'url'], - 'bilibili:dynamic': ['content', 'author', 'url'], - 'bilibili:favorite': ['title', 'author', 'url'], - 'bilibili:user-videos': ['title', 'url', 'published_at'], - 'zhihu:hot': ['title', 'url'], - 'zhihu:question': ['content', 'author'], - 'weibo:hot': ['title', 'url'], - 'v2ex:hot': ['title', 'author', 'url'], - 'v2ex:latest': ['title', 'author', 'url'], - 'xueqiu:hot': ['content', 'author', 'url'], - 'xueqiu:hot-stock': ['title'], - 'xueqiu:stock': ['title'], - 'smzdm:search': ['title', 'url'], - 'boss:search': ['title', 'url'], - 'ctrip:search': ['title', 'url'], - 'xiaoyuzhou:podcast': ['title', 'author', 'content', 'published_at'], - 'xiaoyuzhou:podcast-episodes': ['title', 'published_at'], - 'hackernews:top': ['title', 'author', 'url'], - 'bbc:news': ['title', 'content', 'url'], - 'reuters:search': ['title', 'url', 'published_at'], - 'twitter:trending': ['title'], - 'twitter:timeline': ['content', 'author', 'url', 'published_at'], - 'twitter:search': ['content', 'author', 'url'], - 'twitter:bookmarks': ['title', 'url'], - 'reddit:frontpage': ['title', 'author', 'url'], - 'reddit:hot': ['title', 'url'], - 'reddit:saved': ['title', 'url'], - 'youtube:search': ['title', 'author', 'url'], - 'linkedin:search': ['title', 'url', 'published_at'], - 'yahoo-finance:quote': ['title'], - 'barchart:quote': ['title'], -} - -// Extra fields per site:command that fall through to normalized_data as extra_* -// (fields mapped to standard title/url/content/author/published_at are excluded) -export const SITE_EXTRA_FIELDS: Record = { - 'xiaohongshu:search': ['rank', 'likes'], - 'xiaohongshu:user': ['id', 'type', 'likes'], - 'bilibili:hot': ['rank', 'play', 'danmaku'], - 'bilibili:ranking': ['rank', 'score'], - 'bilibili:dynamic': ['id', 'likes'], - 'bilibili:favorite': ['rank', 'plays'], - 'bilibili:user-videos': ['rank', 'plays', 'likes'], - 'zhihu:hot': ['rank', 'heat', 'answers'], - 'zhihu:question': ['rank', 'votes'], - 'weibo:hot': ['rank', 'hot_value', 'category', 'label'], - 'v2ex:hot': ['rank', 'score'], - 'v2ex:latest': ['rank', 'score'], - 'xueqiu:hot': ['rank', 'likes'], - 'xueqiu:hot-stock': ['rank', 'symbol', 'price', 'changePercent', 'heat'], - 'xueqiu:stock': ['symbol', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], - 'smzdm:search': ['rank', 'price', 'mall', 'comments'], - 'boss:search': ['salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss'], - 'ctrip:search': ['rank', 'type', 'score', 'price'], - 'xiaoyuzhou:podcast': ['subscribers', 'episodes'], - 'xiaoyuzhou:podcast-episodes': ['eid', 'duration', 'plays'], - 'hackernews:top': ['rank', 'score', 'comments'], - 'bbc:news': ['rank'], - 'reuters:search': ['rank', 'section'], - 'twitter:trending': ['rank', 'tweets'], - 'twitter:timeline': ['id', 'likes', 'retweets', 'replies', 'views'], - 'twitter:search': ['id', 'likes', 'views'], - 'twitter:bookmarks': ['score', 'comments'], - 'reddit:frontpage': ['subreddit', 'upvotes', 'comments'], - 'reddit:hot': ['rank', 'subreddit', 'score', 'comments'], - 'reddit:saved': ['subreddit', 'score', 'comments'], - 'youtube:search': ['rank', 'views', 'duration'], - 'linkedin:search': ['rank', 'company', 'location', 'salary'], - 'yahoo-finance:quote': ['symbol', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], - 'barchart:quote': ['symbol', 'price', 'change', 'changePct', 'peRatio', 'eps', 'marketCap'], -} - -export { OPENCLI_PRESETS, PRESET_DEFAULT, SITE_LABELS, COMMANDS_BY_SITE } - -// ── Public component ────────────────────────────────────────────────────────── - -export type ChannelType = 'rss' | 'api' | 'web_scraper' | 'cli' | 'opencli' - -interface Props { - channelType: ChannelType - config: Record - onChange: (config: Record) => void -} - -export default function ChannelConfigForm({ channelType, config, onChange }: Props) { - - switch (channelType) { - case 'rss': - return - case 'api': - return - case 'web_scraper': - return - case 'cli': - return - case 'opencli': - return - } -} + onChange={(e) => onCommandChange(e.target.value)} + disabled={!currentSite || siteCommands.length === 0} + > + + {siteCommands.map((p) => ( + + ))} + + +
+ + {args.length > 0 && ( + + + + )} + + {args.length === 0 && currentCommand && ( +

{t('channelConfig.noArgs')}

+ )} + + + onChange({ ...config, format: v })} + options={[ + { value: 'json', label: 'JSON(推荐)' }, + { value: 'table', label: 'Table' }, + { value: 'yaml', label: 'YAML' }, + { value: 'md', label: 'Markdown' }, + { value: 'csv', label: 'CSV' }, + ]} + /> + + +
+ ) +} + +// Standard fields actually populated for each site:command +// (title/url/content/author/published_at — source_id is always injected by pipeline) +export const SITE_STANDARD_FIELDS: Record = { + 'xiaohongshu:search': ['title', 'author', 'url'], + 'xiaohongshu:user': ['title', 'url'], + 'bilibili:hot': ['title', 'author'], + 'bilibili:ranking': ['title', 'author', 'url'], + 'bilibili:dynamic': ['content', 'author', 'url'], + 'bilibili:favorite': ['title', 'author', 'url'], + 'bilibili:user-videos': ['title', 'url', 'published_at'], + 'zhihu:hot': ['title', 'url'], + 'zhihu:question': ['content', 'author'], + 'weibo:hot': ['title', 'url'], + 'v2ex:hot': ['title', 'author', 'url'], + 'v2ex:latest': ['title', 'author', 'url'], + 'xueqiu:hot': ['content', 'author', 'url'], + 'xueqiu:hot-stock': ['title'], + 'xueqiu:stock': ['title'], + 'smzdm:search': ['title', 'url'], + 'boss:search': ['title', 'url'], + 'ctrip:search': ['title', 'url'], + 'xiaoyuzhou:podcast': ['title', 'author', 'content', 'published_at'], + 'xiaoyuzhou:podcast-episodes': ['title', 'published_at'], + 'hackernews:top': ['title', 'author', 'url'], + 'bbc:news': ['title', 'content', 'url'], + 'reuters:search': ['title', 'url', 'published_at'], + 'twitter:trending': ['title'], + 'twitter:timeline': ['content', 'author', 'url', 'published_at'], + 'twitter:search': ['content', 'author', 'url'], + 'twitter:bookmarks': ['title', 'url'], + 'reddit:frontpage': ['title', 'author', 'url'], + 'reddit:hot': ['title', 'url'], + 'reddit:saved': ['title', 'url'], + 'youtube:search': ['title', 'author', 'url'], + 'linkedin:search': ['title', 'url', 'published_at'], + 'yahoo-finance:quote': ['title'], + 'barchart:quote': ['title'], +} + +// Extra fields per site:command that fall through to normalized_data as extra_* +// (fields mapped to standard title/url/content/author/published_at are excluded) +export const SITE_EXTRA_FIELDS: Record = { + 'xiaohongshu:search': ['rank', 'likes'], + 'xiaohongshu:user': ['id', 'type', 'likes'], + 'bilibili:hot': ['rank', 'play', 'danmaku'], + 'bilibili:ranking': ['rank', 'score'], + 'bilibili:dynamic': ['id', 'likes'], + 'bilibili:favorite': ['rank', 'plays'], + 'bilibili:user-videos': ['rank', 'plays', 'likes'], + 'zhihu:hot': ['rank', 'heat', 'answers'], + 'zhihu:question': ['rank', 'votes'], + 'weibo:hot': ['rank', 'hot_value', 'category', 'label'], + 'v2ex:hot': ['rank', 'score'], + 'v2ex:latest': ['rank', 'score'], + 'xueqiu:hot': ['rank', 'likes'], + 'xueqiu:hot-stock': ['rank', 'symbol', 'price', 'changePercent', 'heat'], + 'xueqiu:stock': ['symbol', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], + 'smzdm:search': ['rank', 'price', 'mall', 'comments'], + 'boss:search': ['salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss'], + 'ctrip:search': ['rank', 'type', 'score', 'price'], + 'xiaoyuzhou:podcast': ['subscribers', 'episodes'], + 'xiaoyuzhou:podcast-episodes': ['eid', 'duration', 'plays'], + 'hackernews:top': ['rank', 'score', 'comments'], + 'bbc:news': ['rank'], + 'reuters:search': ['rank', 'section'], + 'twitter:trending': ['rank', 'tweets'], + 'twitter:timeline': ['id', 'likes', 'retweets', 'replies', 'views'], + 'twitter:search': ['id', 'likes', 'views'], + 'twitter:bookmarks': ['score', 'comments'], + 'reddit:frontpage': ['subreddit', 'upvotes', 'comments'], + 'reddit:hot': ['rank', 'subreddit', 'score', 'comments'], + 'reddit:saved': ['subreddit', 'score', 'comments'], + 'youtube:search': ['rank', 'views', 'duration'], + 'linkedin:search': ['rank', 'company', 'location', 'salary'], + 'yahoo-finance:quote': ['symbol', 'price', 'change', 'changePercent', 'open', 'high', 'low', 'volume', 'marketCap'], + 'barchart:quote': ['symbol', 'price', 'change', 'changePct', 'peRatio', 'eps', 'marketCap'], +} + +export { OPENCLI_PRESETS, PRESET_DEFAULT, SITE_LABELS, COMMANDS_BY_SITE } + +// ── Public component ────────────────────────────────────────────────────────── + +export type ChannelType = 'rss' | 'api' | 'web_scraper' | 'cli' | 'opencli' + +interface Props { + channelType: ChannelType + config: Record + onChange: (config: Record) => void +} + +export default function ChannelConfigForm({ channelType, config, onChange }: Props) { + + switch (channelType) { + case 'rss': + return + case 'api': + return + case 'web_scraper': + return + case 'cli': + return + case 'opencli': + return + } +} diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 00000000..c7e970f6 --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { Command } from 'cmdk' +import { + Bell, + Bot, + Database, + FileText, + Gauge, + KeyRound, + ListChecks, + Network, + Search, + Server, + Settings, + Workflow, + X, +} from 'lucide-react' +import { isTopologyLabEnabled } from '../labs/topology/flags' + +interface CommandAction { + id: string + label: string + hint: string + keywords: string[] + to: string + icon: typeof Gauge +} + +export default function CommandPalette() { + const { t } = useTranslation() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + + const actions = useMemo( + () => [ + { + id: 'dashboard', + label: t('nav.dashboard'), + hint: 'overview health stats', + keywords: ['dashboard', 'overview', '仪表盘', '概览'], + to: '/dashboard', + icon: Gauge, + }, + ...(isTopologyLabEnabled + ? [ + { + id: 'topology', + label: t('nav.topology'), + hint: 'node graph data flow', + keywords: ['topology', 'graph', 'node', 'flow', '拓扑', '节点'], + to: '/labs/topology', + icon: Workflow, + }, + ] + : []), + { + id: 'records', + label: t('nav.records'), + hint: 'collected data notebook', + keywords: ['records', 'data', 'notes', '采集记录', '笔记', '数据'], + to: '/records', + icon: FileText, + }, + { + id: 'tasks', + label: t('nav.tasks'), + hint: 'runs failures events', + keywords: ['tasks', 'runs', 'failed', '任务', '失败', '运行'], + to: '/tasks', + icon: ListChecks, + }, + { + id: 'sources', + label: t('nav.sources'), + hint: 'channels feeds sites', + keywords: ['sources', 'channels', 'feeds', '数据源', '来源'], + to: '/sources', + icon: Database, + }, + { + id: 'nodes', + label: t('nav.browsers'), + hint: 'edge collection nodes', + keywords: ['nodes', 'browser', 'agent', '采集节点', '浏览器'], + to: '/nodes', + icon: Network, + }, + { + id: 'agents', + label: t('nav.agents'), + hint: 'ai processors prompts', + keywords: ['agents', 'ai', 'prompt', '智能体'], + to: '/agents', + icon: Bot, + }, + { + id: 'providers', + label: t('nav.providers'), + hint: 'model providers keys', + keywords: ['providers', 'models', 'keys', '模型', '提供商'], + to: '/providers', + icon: KeyRound, + }, + { + id: 'notifications', + label: t('nav.notifications'), + hint: 'webhook ack delivery', + keywords: ['notifications', 'webhook', 'ack', '通知', '回执'], + to: '/notifications', + icon: Bell, + }, + { + id: 'settings', + label: t('nav.settings'), + hint: t('command.openSettings'), + keywords: ['settings', 'preferences', 'configure', '设置', '偏好'], + to: '/settings', + icon: Settings, + }, + { + id: 'workers', + label: t('nav.workers'), + hint: 'celery workers chrome pool', + keywords: ['workers', 'celery', 'chrome', '工作节点'], + to: '/workers', + icon: Server, + }, + ], + [t], + ) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + setOpen((value) => !value) + } + if (event.key === 'Escape') { + setOpen(false) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + const run = (to: string) => { + navigate(to) + setOpen(false) + } + + if (!open) return null + + return ( +
setOpen(false)} + > + event.stopPropagation()} + > +
+ + + +
+ + + {t('command.empty')} + + + {actions.map((action) => { + const Icon = action.icon + return ( + run(action.to)} + className="flex cursor-pointer items-center gap-3 border border-transparent px-3 py-2.5 text-sm text-zinc-300 aria-selected:border-primary-500/50 aria-selected:bg-primary-500/15 aria-selected:text-white" + > + + {action.label} + + {action.hint} + + + ) + })} + + +
+ {t('command.footer')} + Esc +
+
+
+ ) +} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index 1bbf7b54..e766bb4f 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -1,58 +1,58 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { cn } from '@/lib/utils' -import { buttonVariants } from '@/components/ui/button' - -interface ConfirmDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - title: string - description?: string - confirmLabel?: string - variant?: 'destructive' | 'default' - onConfirm: () => void -} - -export default function ConfirmDialog({ - open, - onOpenChange, - title, - description, - confirmLabel = '确认删除', - variant = 'destructive', - onConfirm, -}: ConfirmDialogProps) { - return ( - - - - {title} - {description && ( - {description} - )} - - - 取消 - - {confirmLabel} - - - - - ) -} +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + confirmLabel?: string + variant?: 'destructive' | 'default' + onConfirm: () => void +} + +export default function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = '确认删除', + variant = 'destructive', + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + {title} + {description && ( + {description} + )} + + + 取消 + + {confirmLabel} + + + + + ) +} diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 845de263..ccf61434 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -1,60 +1,60 @@ -interface Column { - key: string - header: string - render: (row: T) => React.ReactNode - width?: string -} - -interface Props { - columns: Column[] - data: T[] - keyFn: (row: T) => string - emptyMessage?: string - emptyComponent?: React.ReactNode -} - -export default function DataTable({ columns, data, keyFn, emptyMessage = 'No data', emptyComponent }: Props) { - return ( -
-
触发方式耗时
- - - {columns.map((col) => ( - - ))} - - - - {data.length === 0 ? ( - - - - ) : ( - data.map((row) => ( - - {columns.map((col) => ( - - ))} - - )) - )} - -
- {col.header} -
- {emptyComponent ?? ( -
{emptyMessage}
- )} -
- {col.render(row)} -
- - ) -} +interface Column { + key: string + header: string + render: (row: T) => React.ReactNode + width?: string +} + +interface Props { + columns: Column[] + data: T[] + keyFn: (row: T) => string + emptyMessage?: string + emptyComponent?: React.ReactNode +} + +export default function DataTable({ columns, data, keyFn, emptyMessage = 'No data', emptyComponent }: Props) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((row) => ( + + {columns.map((col) => ( + + ))} + + )) + )} + +
+ {col.header} +
+ {emptyComponent ?? ( +
{emptyMessage}
+ )} +
+ {col.render(row)} +
+
+ ) +} diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx index d25f8b66..18f6d86e 100644 --- a/frontend/src/components/EmptyState.tsx +++ b/frontend/src/components/EmptyState.tsx @@ -1,38 +1,38 @@ -import type { ElementType } from 'react' - -interface EmptyStateProps { - icon?: ElementType - title: string - description?: string - action?: { label: string; onClick: () => void } -} - -export default function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { - return ( -
- {Icon && ( - - )} -

- {title} -

- {description && ( -

- {description} -

- )} - {action && ( - - )} -
- ) -} +import type { ElementType } from 'react' + +interface EmptyStateProps { + icon?: ElementType + title: string + description?: string + action?: { label: string; onClick: () => void } +} + +export default function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { + return ( +
+ {Icon && ( + + )} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} + {action && ( + + )} +
+ ) +} diff --git a/frontend/src/components/ErrorAlert.tsx b/frontend/src/components/ErrorAlert.tsx index 68a24515..dcf5b8b8 100644 --- a/frontend/src/components/ErrorAlert.tsx +++ b/frontend/src/components/ErrorAlert.tsx @@ -8,14 +8,14 @@ interface Props { export default function ErrorAlert({ error, onRetry }: Props) { const message = error instanceof Error ? error.message : error return ( -
- +
+
-

{message}

+

{message}

{onRetry && ( diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 6997272a..c4472437 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,43 +1,43 @@ -import { Component, type ReactNode } from 'react' - -interface Props { - children: ReactNode - fallback?: ReactNode -} - -interface State { - hasError: boolean - error: Error | null -} - -export default class ErrorBoundary extends Component { - state: State = { hasError: false, error: null } - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } - } - - componentDidCatch(error: Error, info: { componentStack: string }) { - console.error('[ErrorBoundary]', error, info.componentStack) - } - - reset = () => this.setState({ hasError: false, error: null }) - - render() { - if (this.state.hasError) { - return this.props.fallback ?? ( -
-

页面渲染出错

-

{this.state.error?.message}

- -
- ) - } - return this.props.children - } -} +import { Component, type ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export default class ErrorBoundary extends Component { + state: State = { hasError: false, error: null } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, info: { componentStack: string }) { + console.error('[ErrorBoundary]', error, info.componentStack) + } + + reset = () => this.setState({ hasError: false, error: null }) + + render() { + if (this.state.hasError) { + return this.props.fallback ?? ( +
+

页面渲染出错

+

{this.state.error?.message}

+ +
+ ) + } + return this.props.children + } +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f1dc0409..383b16ec 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,7 @@ -import { Outlet, NavLink, useLocation } from 'react-router-dom' import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' +import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom' import ErrorBoundary from './ErrorBoundary' import { LayoutDashboard, @@ -17,39 +17,51 @@ import { KeyRound, ChevronLeft, ChevronRight, - Moon, - Sun, - Languages, + ChevronDown, Home, + Settings, + SlidersHorizontal, + Blocks, } from 'lucide-react' import { clsx } from 'clsx' import { getDashboardStats } from '../api/endpoints' +import CommandPalette from './CommandPalette' +import { + SETTINGS_EVENT, + applyThemePreference, + getThemePreference, +} from '../lib/preferences' +import { isTopologyLabEnabled } from '../labs/topology/flags' -const ROUTE_LABELS: Record = { - '/dashboard': '数据看板', - '/sources': '数据源', - '/tasks': '任务', - '/records': '采集记录', - '/schedules': '定时任务', - '/notifications': '通知', - '/nodes': '采集节点', - '/workers': 'Workers', - '/providers': 'AI 提供商', - '/agents': 'Agents', +const ROUTE_LABEL_KEYS: Record = { + '/dashboard': 'nav.dashboard', + '/labs/topology': 'nav.topology', + '/sources': 'nav.sources', + '/tasks': 'nav.tasks', + '/records': 'nav.records', + '/schedules': 'nav.schedules', + '/notifications': 'nav.notifications', + '/nodes': 'nav.browsers', + '/workers': 'nav.workers', + '/providers': 'nav.providers', + '/agents': 'nav.agents', + '/settings': 'nav.settings', } function Breadcrumb() { const { pathname } = useLocation() - const label = ROUTE_LABELS[pathname] + const { t } = useTranslation() + const routeLabelKey = ROUTE_LABEL_KEYS[pathname] + const label = routeLabelKey ? t(routeLabelKey) : '' return ( -
- - 首页 - {label && ( +
+ + {t('nav.home')} + {routeLabelKey && ( <> - / - {label} + / + {label} )}
@@ -57,12 +69,15 @@ function Breadcrumb() { } export default function Layout() { - const { t, i18n } = useTranslation() + const { t } = useTranslation() const location = useLocation() + const navigate = useNavigate() const [collapsed, setCollapsed] = useState(false) - const [dark, setDark] = useState(() => { - return localStorage.getItem('theme') === 'dark' - }) + const [advancedOpen, setAdvancedOpen] = useState(false) + const [dark, setDark] = useState(() => getThemePreference() === 'dark') + const [isNarrow, setIsNarrow] = useState(() => + typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false + ) const { data: statsData } = useQuery({ queryKey: ['dashboard-stats-badge'], @@ -73,134 +88,222 @@ export default function Layout() { const failedCount = statsData?.tasks?.failed ?? 0 useEffect(() => { - if (dark) { - document.documentElement.classList.add('dark') - } else { - document.documentElement.classList.remove('dark') + applyThemePreference(dark ? 'dark' : 'light') + }, [dark]) + + useEffect(() => { + const onSettingsChanged = () => { + setDark(getThemePreference() === 'dark') + } + if (typeof window !== 'undefined') { + window.addEventListener(SETTINGS_EVENT, onSettingsChanged) + } + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener(SETTINGS_EVENT, onSettingsChanged) + } } }, []) - const NAV_ITEMS = [ - { to: '/dashboard', label: t('nav.dashboard'), icon: LayoutDashboard }, - { to: '/sources', label: t('nav.sources'), icon: Database }, - { to: '/tasks', label: t('nav.tasks'), icon: ListChecks }, - { to: '/records', label: t('nav.records'), icon: FileText }, - { to: '/schedules', label: t('nav.schedules'), icon: Clock }, - { to: '/agents', label: t('nav.agents'), icon: Bot }, - { to: '/providers', label: t('nav.providers'), icon: KeyRound }, - { to: '/nodes', label: t('nav.browsers'), icon: Network }, - { to: '/notifications', label: t('nav.notifications'), icon: Bell }, - { to: '/workers', label: t('nav.workers'), icon: Server }, - ] + useEffect(() => { + if (typeof window === 'undefined') { + return + } - const toggleDark = () => { - setDark((prev) => { - const next = !prev - localStorage.setItem('theme', next ? 'dark' : 'light') - if (next) { - document.documentElement.classList.add('dark') - } else { - document.documentElement.classList.remove('dark') - } - return next - }) + const mediaQuery = window.matchMedia('(max-width: 767px)') + const onChange = () => setIsNarrow(mediaQuery.matches) + + onChange() + mediaQuery.addEventListener('change', onChange) + return () => mediaQuery.removeEventListener('change', onChange) + }, []) + + const sidebarCollapsed = collapsed || isNarrow + + type NavItem = { to: string; label: string; icon: typeof Database; stage?: string } + type NavGroup = { label: string | null; items: NavItem[] } + + const PIPELINE_GROUP: NavGroup = { + label: '采集管线', + items: [ + { to: '/sources', label: t('nav.sources'), icon: Database, stage: 'IN' }, + { to: '/schedules', label: t('nav.schedules'), icon: Clock, stage: 'TR' }, + { to: '/tasks', label: t('nav.tasks'), icon: ListChecks, stage: 'EX' }, + { to: '/agents', label: t('nav.agents'), icon: Bot, stage: 'PR' }, + { to: '/records', label: t('nav.records'), icon: FileText, stage: 'DB' }, + { to: '/notifications', label: t('nav.notifications'), icon: Bell, stage: 'OUT' }, + ], + } + const INFRA_GROUP: NavGroup = { + label: '基础设施', + items: [ + { to: '/nodes', label: t('nav.browsers'), icon: Chrome }, + { to: '/workers', label: t('nav.workers'), icon: Server }, + { to: '/providers', label: t('nav.providers'), icon: KeyRound }, + ], } - const toggleLang = () => { - const next = i18n.language === 'zh' ? 'en' : 'zh' - i18n.changeLanguage(next) - localStorage.setItem('lang', next) + // Folded IA (new design philosophy): the canvas + agent dock are HOME; the + // 11 CRUD admin pages are demoted into a collapsible "advanced / raw data" + // drawer. Day-to-day = look at the graph, talk to the agent. Routes kept. + const PRIMARY_ITEMS: NavItem[] = [ + { to: '/labs/topology', label: t('nav.workspace'), icon: Network }, + { to: '/labs/node-kit', label: '节点工作台', icon: Blocks }, + { to: '/dashboard', label: t('nav.dashboard'), icon: LayoutDashboard }, + ] + const ADVANCED_GROUPS: NavGroup[] = [PIPELINE_GROUP, INFRA_GROUP] + + // Legacy IA (topology lab off): original flat 3-group nav, unchanged. + const NAV_GROUPS: NavGroup[] = [ + { + label: null, + items: [{ to: '/dashboard', label: t('nav.dashboard'), icon: LayoutDashboard }], + }, + PIPELINE_GROUP, + INFRA_GROUP, + ] + + const renderNavItem = ({ to, label, icon: Icon, stage }: NavItem) => { + const showBadge = to === '/tasks' && failedCount > 0 + return ( + + clsx( + 'group flex items-center gap-3 border border-transparent px-3 py-2 text-sm transition-colors', + isActive + ? 'border-primary-500/45 bg-primary-500/10 text-white' + : 'text-zinc-500 hover:border-white/10 hover:bg-white/[0.04] hover:text-zinc-100' + ) + } + title={sidebarCollapsed ? label : undefined} + > + + {!sidebarCollapsed && ( + + {label} + {showBadge ? ( + + {failedCount} + + ) : stage ? ( + + {stage} + + ) : null} + + )} + + ) } + const renderNavGroup = (group: NavGroup, groupIndex: number) => ( +
+ {group.label && !sidebarCollapsed && ( +

+ {group.label} +

+ )} + {group.label && sidebarCollapsed && ( +
+ )} + {group.items.map(renderNavItem)} +
+ ) + return ( -
+
{/* Sidebar */} {/* Main content */} -
-
+
+
@@ -209,6 +312,7 @@ export default function Layout() {
+
) } diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index ff958176..9b56778e 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -4,7 +4,7 @@ export default function LoadingSpinner({ className }: { className?: string }) { return (
+ + update({ ack_secret: v })} + placeholder={t('notifierConfig.optional')} + type="password" + /> + -
-

{title}

+
+
+

{t('brand.opsConsole')}

+

{title}

{description && ( -

{description}

+

{description}

)}
- {action &&
{action}
} + {action &&
{action}
}
) } diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx index 97786e0d..17477afc 100644 --- a/frontend/src/components/Pagination.tsx +++ b/frontend/src/components/Pagination.tsx @@ -1,91 +1,91 @@ -interface PaginationProps { - page: number - pages: number - total: number - limit: number - onChange: (page: number) => void -} - -function buildPageNumbers(page: number, pages: number): (number | 'ellipsis')[] { - if (pages <= 7) { - return Array.from({ length: pages }, (_, i) => i + 1) - } - - const result: (number | 'ellipsis')[] = [] - - // Always show first page - result.push(1) - - if (page <= 4) { - // Near the start: show 1 2 3 4 5 ... N - for (let i = 2; i <= Math.min(5, pages - 1); i++) result.push(i) - result.push('ellipsis') - } else if (page >= pages - 3) { - // Near the end: show 1 ... N-4 N-3 N-2 N-1 N - result.push('ellipsis') - for (let i = Math.max(2, pages - 4); i <= pages - 1; i++) result.push(i) - } else { - // Middle: show 1 ... p-1 p p+1 ... N - result.push('ellipsis') - for (let i = page - 1; i <= page + 1; i++) result.push(i) - result.push('ellipsis') - } - - // Always show last page - result.push(pages) - - return result -} - -export default function Pagination({ page, pages, total, onChange }: PaginationProps) { - const pageNumbers = buildPageNumbers(page, pages) - - return ( -
- 共 {total} 条 - -
- {/* Prev */} - - - {/* Page numbers */} - {pageNumbers.map((p, i) => - p === 'ellipsis' ? ( - - … - - ) : ( - - ) - )} - - {/* Next */} - -
-
- ) -} +interface PaginationProps { + page: number + pages: number + total: number + limit: number + onChange: (page: number) => void +} + +function buildPageNumbers(page: number, pages: number): (number | 'ellipsis')[] { + if (pages <= 7) { + return Array.from({ length: pages }, (_, i) => i + 1) + } + + const result: (number | 'ellipsis')[] = [] + + // Always show first page + result.push(1) + + if (page <= 4) { + // Near the start: show 1 2 3 4 5 ... N + for (let i = 2; i <= Math.min(5, pages - 1); i++) result.push(i) + result.push('ellipsis') + } else if (page >= pages - 3) { + // Near the end: show 1 ... N-4 N-3 N-2 N-1 N + result.push('ellipsis') + for (let i = Math.max(2, pages - 4); i <= pages - 1; i++) result.push(i) + } else { + // Middle: show 1 ... p-1 p p+1 ... N + result.push('ellipsis') + for (let i = page - 1; i <= page + 1; i++) result.push(i) + result.push('ellipsis') + } + + // Always show last page + result.push(pages) + + return result +} + +export default function Pagination({ page, pages, total, onChange }: PaginationProps) { + const pageNumbers = buildPageNumbers(page, pages) + + return ( +
+ 共 {total} 条 + +
+ {/* Prev */} + + + {/* Page numbers */} + {pageNumbers.map((p, i) => + p === 'ellipsis' ? ( + + … + + ) : ( + + ) + )} + + {/* Next */} + +
+
+ ) +} diff --git a/frontend/src/components/SkeletonLoader.tsx b/frontend/src/components/SkeletonLoader.tsx index 6d74e973..90b60a9b 100644 --- a/frontend/src/components/SkeletonLoader.tsx +++ b/frontend/src/components/SkeletonLoader.tsx @@ -1,63 +1,63 @@ -import { Skeleton } from '@/components/ui/skeleton' - -interface TableSkeletonProps { - rows?: number -} - -export function TableSkeleton({ rows = 5 }: TableSkeletonProps) { - return ( -
- {/* Header row */} -
- - - - - - -
- {/* Data rows */} - {Array.from({ length: rows }).map((_, i) => ( -
- - - - - - -
- ))} -
- ) -} - -interface CardSkeletonProps { - cards?: number -} - -export function CardSkeleton({ cards = 4 }: CardSkeletonProps) { - return ( -
- {Array.from({ length: cards }).map((_, i) => ( -
-
- - -
- - -
- - -
-
- ))} -
- ) -} +import { Skeleton } from '@/components/ui/skeleton' + +interface TableSkeletonProps { + rows?: number +} + +export function TableSkeleton({ rows = 5 }: TableSkeletonProps) { + return ( +
+ {/* Header row */} +
+ + + + + + +
+ {/* Data rows */} + {Array.from({ length: rows }).map((_, i) => ( +
+ + + + + + +
+ ))} +
+ ) +} + +interface CardSkeletonProps { + cards?: number +} + +export function CardSkeleton({ cards = 4 }: CardSkeletonProps) { + return ( +
+ {Array.from({ length: cards }).map((_, i) => ( +
+
+ + +
+ + +
+ + +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index e74922f2..d34626e1 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,18 +1,20 @@ import { clsx } from 'clsx' const STATUS_STYLES: Record = { - pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', - running: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', - ai_processing: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', - completed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', - failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', - cancelled: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', - online: 'bg-green-100 text-green-800', - offline: 'bg-gray-100 text-gray-700', - sent: 'bg-green-100 text-green-800', - raw: 'bg-gray-100 text-gray-700', - normalized: 'bg-blue-100 text-blue-800', - ai_processed: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + pending: 'border-signal-amber/40 bg-signal-amber/10 text-amber-200', + running: 'border-zinc-200/50 bg-zinc-100/10 text-zinc-100', + ai_processing: 'border-primary-500/50 bg-primary-500/10 text-primary-100', + completed: 'border-signal-green/40 bg-signal-green/10 text-emerald-200', + failed: 'border-signal-red/60 bg-signal-red/15 text-red-100', + cancelled: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300', + online: 'border-signal-green/40 bg-signal-green/10 text-emerald-200', + offline: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300', + sent: 'border-signal-green/40 bg-signal-green/10 text-emerald-200', + acked: 'border-signal-green/40 bg-signal-green/10 text-emerald-200', + not_required: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300', + raw: 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300', + normalized: 'border-zinc-200/50 bg-zinc-100/10 text-zinc-100', + ai_processed: 'border-primary-500/50 bg-primary-500/10 text-primary-100', } const STATUS_LABELS: Record = { @@ -26,6 +28,8 @@ const STATUS_LABELS: Record = { normalized: '已归一化', ai_processed: '已处理', sent: '已发送', + acked: '已回执', + not_required: '无需回执', online: '在线', offline: '离线', } @@ -36,12 +40,12 @@ interface Props { } export default function StatusBadge({ status, className }: Props) { - const style = STATUS_STYLES[status] ?? 'bg-gray-100 text-gray-700' + const style = STATUS_STYLES[status] ?? 'border-zinc-500/40 bg-zinc-500/10 text-zinc-300' const label = STATUS_LABELS[status] ?? status return ( (null) - const hideTimer = useRef>() - - const open = (e: React.MouseEvent) => { - clearTimeout(hideTimer.current) - setRect(e.currentTarget.getBoundingClientRect()) - } - - const close = () => { - hideTimer.current = setTimeout(() => setRect(null), 120) - } - - const keepOpen = () => clearTimeout(hideTimer.current) - - // Compute popover position: flip above if too close to bottom of viewport - const popoverStyle = (): React.CSSProperties => { - if (!rect) return {} - const spaceBelow = window.innerHeight - rect.bottom - const popoverH = 200 - const top = spaceBelow > popoverH + 12 ? rect.bottom + 6 : rect.top - popoverH - 6 - return { - position: 'fixed', - top, - left: rect.left, - width: Math.max(rect.width, 320), - maxWidth: Math.min(480, window.innerWidth - rect.left - 12), - zIndex: 9999, - } - } - - return ( - <> -
- {text} -
- - {rect && - createPortal( -
- {text} -
, - document.body, - )} - - ) -} +import { useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +interface Props { + text: string + lines?: number + className?: string +} + +export default function TruncatedText({ text, lines = 2, className = '' }: Props) { + const [rect, setRect] = useState(null) + const hideTimer = useRef>() + + const open = (e: React.MouseEvent) => { + clearTimeout(hideTimer.current) + setRect(e.currentTarget.getBoundingClientRect()) + } + + const close = () => { + hideTimer.current = setTimeout(() => setRect(null), 120) + } + + const keepOpen = () => clearTimeout(hideTimer.current) + + // Compute popover position: flip above if too close to bottom of viewport + const popoverStyle = (): React.CSSProperties => { + if (!rect) return {} + const spaceBelow = window.innerHeight - rect.bottom + const popoverH = 200 + const top = spaceBelow > popoverH + 12 ? rect.bottom + 6 : rect.top - popoverH - 6 + return { + position: 'fixed', + top, + left: rect.left, + width: Math.max(rect.width, 320), + maxWidth: Math.min(480, window.innerWidth - rect.left - 12), + zIndex: 9999, + } + } + + return ( + <> +
+ {text} +
+ + {rect && + createPortal( +
+ {text} +
, + document.body, + )} + + ) +} diff --git a/frontend/src/components/opencli/MetricTile.tsx b/frontend/src/components/opencli/MetricTile.tsx new file mode 100644 index 00000000..94d31c62 --- /dev/null +++ b/frontend/src/components/opencli/MetricTile.tsx @@ -0,0 +1,87 @@ +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type MetricTone = 'neutral' | 'accent' | 'info' | 'gold' | 'success' | 'warning' | 'danger' | 'violet' + +const TONE_STYLES: Record = { + neutral: { + rail: 'bg-zinc-300', + icon: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-200', + value: 'text-zinc-50', + }, + accent: { + rail: 'bg-primary-500', + icon: 'border-primary-500/40 bg-primary-500/10 text-primary-100', + value: 'text-zinc-50', + }, + info: { + rail: 'bg-signal-cyan', + icon: 'border-signal-cyan/40 bg-signal-cyan/10 text-sky-100', + value: 'text-sky-100', + }, + gold: { + rail: 'bg-signal-gold', + icon: 'border-signal-gold/40 bg-signal-gold/10 text-yellow-100', + value: 'text-yellow-100', + }, + success: { + rail: 'bg-signal-green', + icon: 'border-signal-green/40 bg-signal-green/10 text-emerald-100', + value: 'text-emerald-100', + }, + warning: { + rail: 'bg-signal-amber', + icon: 'border-signal-amber/40 bg-signal-amber/10 text-amber-100', + value: 'text-amber-100', + }, + danger: { + rail: 'bg-signal-red', + icon: 'border-signal-red/50 bg-signal-red/14 text-red-100', + value: 'text-red-100', + }, + violet: { + rail: 'bg-signal-violet', + icon: 'border-signal-violet/40 bg-signal-violet/10 text-violet-100', + value: 'text-violet-100', + }, +} + +interface MetricTileProps { + label: string + value: React.ReactNode + sub?: React.ReactNode + icon?: LucideIcon + tone?: MetricTone + className?: string +} + +export function MetricTile({ + label, + value, + sub, + icon: Icon, + tone = 'neutral', + className, +}: MetricTileProps) { + const style = TONE_STYLES[tone] + + return ( +
+
+
+
+

{label}

+

+ {value} +

+
+ {Icon && ( + + + + )} +
+ {sub &&
{sub}
} +
+ ) +} diff --git a/frontend/src/components/opencli/OperatorCard.tsx b/frontend/src/components/opencli/OperatorCard.tsx new file mode 100644 index 00000000..15414046 --- /dev/null +++ b/frontend/src/components/opencli/OperatorCard.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +export type OperatorTone = + | 'neutral' + | 'accent' + | 'info' + | 'gold' + | 'success' + | 'warning' + | 'danger' + | 'violet' + +const TONE_STYLES: Record = { + neutral: 'border-white/10 bg-white/[0.035] text-zinc-300', + accent: 'border-primary-500/45 bg-primary-500/12 text-primary-100', + info: 'border-signal-cyan/45 bg-signal-cyan/12 text-sky-100', + gold: 'border-signal-gold/45 bg-signal-gold/12 text-yellow-100', + success: 'border-signal-green/45 bg-signal-green/12 text-emerald-100', + warning: 'border-signal-amber/45 bg-signal-amber/12 text-amber-100', + danger: 'border-signal-red/50 bg-signal-red/14 text-red-100', + violet: 'border-signal-violet/45 bg-signal-violet/12 text-violet-100', +} + +export interface OperatorCardProps { + label: string + value: ReactNode + hint?: string + icon: LucideIcon + tone?: OperatorTone + active?: boolean + onClick?: () => void +} + +export function OperatorCard({ + label, + value, + hint, + icon: Icon, + tone = 'neutral', + active = false, + onClick, +}: OperatorCardProps) { + const toneClassName = TONE_STYLES[tone] + const body = ( + <> +
+ + + + {value} +
+

{label}

+ {hint &&

{hint}

} + + ) + + if (onClick) { + return ( + + ) + } + + return ( +
+ {body} +
+ ) +} diff --git a/frontend/src/components/opencli/PanelHeader.tsx b/frontend/src/components/opencli/PanelHeader.tsx new file mode 100644 index 00000000..7907569d --- /dev/null +++ b/frontend/src/components/opencli/PanelHeader.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils' + +interface PanelHeaderProps { + label: string + title: React.ReactNode + description?: React.ReactNode + actions?: React.ReactNode + className?: string +} + +export function PanelHeader({ + label, + title, + description, + actions, + className, +}: PanelHeaderProps) { + return ( +
+
+

{label}

+
{title}
+ {description &&
{description}
} +
+ {actions &&
{actions}
} +
+ ) +} diff --git a/frontend/src/components/opencli/PlaybackControls.tsx b/frontend/src/components/opencli/PlaybackControls.tsx new file mode 100644 index 00000000..d9f47f2d --- /dev/null +++ b/frontend/src/components/opencli/PlaybackControls.tsx @@ -0,0 +1,47 @@ +import { Pause, Play, SkipBack, SkipForward } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +interface PlaybackControlsProps { + playing: boolean + disabled?: boolean + progressLabel: string + onToggle: () => void + onPrevious: () => void + onNext: () => void + onReset: () => void + className?: string +} + +export function PlaybackControls({ + playing, + disabled, + progressLabel, + onToggle, + onPrevious, + onNext, + onReset, + className, +}: PlaybackControlsProps) { + return ( +
+ + + + + + {progressLabel} + +
+ ) +} diff --git a/frontend/src/components/opencli/WorkbenchPanel.tsx b/frontend/src/components/opencli/WorkbenchPanel.tsx new file mode 100644 index 00000000..7066ba52 --- /dev/null +++ b/frontend/src/components/opencli/WorkbenchPanel.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' +import Card from '../Card' +import { cn } from '@/lib/utils' + +export function WorkbenchPanel({ + label, + title, + description, + action, + className, + children, +}: { + label: string + title: ReactNode + description?: ReactNode + action?: ReactNode + className?: string + children: ReactNode +}) { + return ( + +
+
+
+

{label}

+
{title}
+ {description &&
{description}
} +
+ {action &&
{action}
} +
+
+ {children} +
+ ) +} diff --git a/frontend/src/components/opencli/index.ts b/frontend/src/components/opencli/index.ts new file mode 100644 index 00000000..3c7f1f8d --- /dev/null +++ b/frontend/src/components/opencli/index.ts @@ -0,0 +1,6 @@ +export { MetricTile } from './MetricTile' +export { OperatorCard } from './OperatorCard' +export type { OperatorTone } from './OperatorCard' +export { PanelHeader } from './PanelHeader' +export { PlaybackControls } from './PlaybackControls' +export { WorkbenchPanel } from './WorkbenchPanel' diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx index 8722561c..c713ff0b 100644 --- a/frontend/src/components/ui/alert-dialog.tsx +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -1,139 +1,139 @@ -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" - -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" - -const AlertDialog = AlertDialogPrimitive.Root - -const AlertDialogTrigger = AlertDialogPrimitive.Trigger - -const AlertDialogPortal = AlertDialogPrimitive.Portal - -const AlertDialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + - -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName - -const AlertDialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -AlertDialogHeader.displayName = "AlertDialogHeader" - -const AlertDialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -AlertDialogFooter.displayName = "AlertDialogFooter" - -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName - -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName - -const AlertDialogAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName - -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName - -export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -} + {...props} + /> + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index f000e3ef..6462f1a0 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -1,36 +1,36 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded-[2px] border px-2 py-0.5 font-telemetry text-[10px] font-semibold uppercase tracking-[0.12em] transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-0", { variants: { variant: { default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + "border-primary-500/50 bg-primary-500/12 text-primary-100 hover:bg-primary-500/18", secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + "border-white/12 bg-white/[0.045] text-zinc-300 hover:bg-white/[0.075]", destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - outline: "text-foreground", + "border-signal-red/70 bg-signal-red/18 text-red-50 hover:bg-signal-red/24", + outline: "border-white/14 bg-transparent text-zinc-300", }, }, - defaultVariants: { - variant: "default", - }, - } -) - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ) -} - -export { Badge, badgeVariants } + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 36496a28..6c63196d 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,56 +1,57 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[2px] border font-telemetry text-[11px] font-semibold uppercase tracking-[0.12em] ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/70 focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: "border-primary-500/70 bg-primary-500/16 text-white hover:border-primary-400 hover:bg-primary-500/24", destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", + "border-signal-red/80 bg-signal-red/22 text-red-50 hover:border-signal-red hover:bg-signal-red/30", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + "border-white/14 bg-black/25 text-zinc-200 hover:border-white/28 hover:bg-white/[0.075] hover:text-white", secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + "border-white/10 bg-white/[0.045] text-zinc-200 hover:border-white/22 hover:bg-white/[0.08] hover:text-white", + ghost: "border-transparent bg-transparent text-zinc-400 hover:border-white/12 hover:bg-white/[0.055] hover:text-white", + link: "border-transparent bg-transparent px-0 text-primary-300 underline-offset-4 hover:text-primary-100 hover:underline", }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", + default: "h-9 px-3 py-2", + sm: "h-8 px-2.5", + lg: "h-10 px-4", + icon: "h-9 w-9", + xs: "h-7 px-2 text-[10px]", }, }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean -} - -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } -) -Button.displayName = "Button" - -export { Button, buttonVariants } + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index c680b9d3..f5801aaf 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -1,120 +1,120 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Dialog = DialogPrimitive.Root - -const DialogTrigger = DialogPrimitive.Trigger - -const DialogPortal = DialogPrimitive.Portal - -const DialogClose = DialogPrimitive.Close - -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName - -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
+ {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + -) -DialogHeader.displayName = "DialogHeader" - -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = "DialogFooter" - -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName - -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName - -export { - Dialog, - DialogPortal, - DialogOverlay, - DialogClose, - DialogTrigger, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} + {...props} + /> +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 68551b92..7d38451c 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,22 +1,22 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( - ) - } -) -Input.displayName = "Input" - -export { Input } + {...props} + /> + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index d826bdba..70cb43fb 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -1,158 +1,158 @@ -import * as React from "react" -import * as SelectPrimitive from "@radix-ui/react-select" -import { Check, ChevronDown, ChevronUp } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Select = SelectPrimitive.Root - -const SelectGroup = SelectPrimitive.Group - -const SelectValue = SelectPrimitive.Value - -const SelectTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( span]:line-clamp-1", + "flex h-9 w-full items-center justify-between rounded-[2px] border border-white/14 bg-black/35 px-3 py-2 text-sm text-zinc-100 ring-offset-background data-[placeholder]:text-zinc-600 focus:border-primary-500/70 focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} - {...props} - > - {children} - - - - -)) -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName - -const SelectScrollUpButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName - -const SelectScrollDownButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName - -const SelectContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( - - + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + - - - {children} - - - - -)) -SelectContent.displayName = SelectPrimitive.Content.displayName - -const SelectLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + className + )} + position={position} + {...props} + > + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -)) -SelectLabel.displayName = SelectPrimitive.Label.displayName - -const SelectItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)) -SelectItem.displayName = SelectPrimitive.Item.displayName - -const SelectSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + {...props} + > + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( -)) -SelectSeparator.displayName = SelectPrimitive.Separator.displayName - -export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, -} +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx index 6d7f1226..9864754a 100644 --- a/frontend/src/components/ui/separator.tsx +++ b/frontend/src/components/ui/separator.tsx @@ -1,29 +1,29 @@ -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" - -import { cn } from "@/lib/utils" - -const Separator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->( - ( - { className, orientation = "horizontal", decorative = true, ...props }, - ref - ) => ( - - ) -) -Separator.displayName = SeparatorPrimitive.Root.displayName - -export { Separator } +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx index 01b8b6d4..54b06edc 100644 --- a/frontend/src/components/ui/skeleton.tsx +++ b/frontend/src/components/ui/skeleton.tsx @@ -1,15 +1,15 @@ -import { cn } from "@/lib/utils" - -function Skeleton({ - className, - ...props -}: React.HTMLAttributes) { - return ( +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return (
- ) -} - -export { Skeleton } + ) +} + +export { Skeleton } diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx index e1ae87b9..c0814451 100644 --- a/frontend/src/components/ui/tooltip.tsx +++ b/frontend/src/components/ui/tooltip.tsx @@ -1,30 +1,30 @@ -"use client" - -import * as React from "react" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" - -import { cn } from "@/lib/utils" - -const TooltipProvider = TooltipPrimitive.Provider - -const Tooltip = TooltipPrimitive.Root - -const TooltipTrigger = TooltipPrimitive.Trigger - -const TooltipContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - , + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + -)) -TooltipContent.displayName = TooltipPrimitive.Content.displayName - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } + {...props} + /> +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index af70239e..4883c341 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -2,19 +2,24 @@ import type { Translations } from './zh' const en: Translations = { nav: { + home: 'HOME', dashboard: 'Dashboard', + topology: 'Topology', + workspace: 'Collection Network', + advanced: 'Advanced · Raw Data', sources: 'Sources', tasks: 'Tasks', records: 'Records', schedules: 'Schedules', agents: 'Agents', providers: 'Providers', - notifications: 'Notifications', - workers: 'Workers', - browsers: 'Nodes', - collapse: 'Collapse', - dark: 'Dark', - light: 'Light', + notifications: 'Notifications', + workers: 'Workers', + browsers: 'Nodes', + settings: 'Settings', + collapse: 'Collapse', + dark: 'Dark', + light: 'Light', }, common: { cancel: 'Cancel', @@ -46,6 +51,185 @@ const en: Translations = { createdAt: 'Created', updatedAt: 'Updated', }, + brand: { + subtitle: 'Data Ops Console', + opsConsole: 'OPS CONSOLE', + }, + command: { + title: 'Command palette', + placeholder: 'Jump to records, tasks, nodes…', + empty: 'No matching action', + navigation: 'Navigation', + openSettings: 'Open settings', + footer: 'Type a keyword, then press Enter', + }, + settings: { + title: 'Settings', + description: 'Manage UI preferences, theme, and visual defaults in one place.', + conversation: { + title: 'Conversation Execution', + description: 'Run node actions with a single conversational command, now supporting source/task trigger workflows.', + placeholder: 'Example: trigger source abc123 or run task abc123 --chrome_endpoint=http://chrome-1', + tip: 'Supported commands include source/task trigger actions; agent IDs/parameters will be parsed when present.', + run: 'Run Command', + running: 'Running…', + failed: 'Run failed', + errors: { + emptyInput: 'Enter a conversation command', + unsupportedType: 'Command type not recognized. Example: trigger source or run task ', + missingEntity: 'Entity ID not recognized. Example: trigger source ', + unsupportedTarget: 'This target type does not support executable actions yet', + unknown: 'Unknown error', + }, + }, + language: { + title: 'Language', + description: 'Choose interface language. zh/en are enabled now, extensible to more.', + }, + theme: { + title: 'Theme', + description: 'Switch between dark and light themes. Changes apply immediately.', + light: 'Light', + dark: 'Dark', + }, + density: { + title: 'Layout Density (reserved)', + description: 'Density controls are persisted now and can be wired to list/card rendering later.', + compact: 'Compact', + comfortable: 'Comfortable', + spacious: 'Spacious', + }, + experimental: { + title: 'Dev shortcuts', + description: 'Quick access to graph and source canvases for behavior validation.', + openTopology: 'Open topology', + openSources: 'Open sources', + }, + reset: { + title: 'Developer tools', + description: 'Clear local preference keys (theme/language/density) and reset defaults.', + label: 'Clear local preferences', + }, + }, + topology: { + title: 'Topology', + description: 'Runtime relationships from sources to tasks, agents, records, and notifications', + focusDescription: 'Focused on source: {{name}}', + refresh: 'Refresh', + quickJump: 'Quick jump', + allNodes: 'All nodes', + running: 'Running', + needsFocus: 'Needs focus', + ready: 'Ready', + missingSkills: 'Missing skills', + refreshing: 'Refreshing…', + edgeCount: '{{count}} edges', + selectNode: 'Select a node to inspect context', + openDetail: 'Open detail', + clearFocus: 'Clear focus', + modes: { + flow: 'Flow', + health: 'Health', + skills: 'Skills', + }, + kinds: { + source: 'Source', + schedule: 'Plan', + task: 'Task', + agent: 'Agent', + record: 'Record', + notification: 'Notify', + edgeNode: 'Edge', + worker: 'Worker', + }, + health: { + healthy: 'Healthy', + active: 'Running', + warning: 'Attention', + failed: 'Failed', + disabled: 'Disabled', + unknown: 'Unknown', + }, + inspector: { + skillMatrix: 'Skill Matrix', + skillSummary: '{{ready}} ready · {{missing}} missing', + needsSkill: 'Needs Skill', + ready: 'Ready', + completeCapability: 'Complete Capability', + actions: 'Node Actions', + run: 'Run', + done: 'Done', + failed: 'Failed', + detail: 'Detail', + }, + skills: { + collect: { + label: 'Collect', + ready: 'Channel can be triggered', + blocked: 'Source is disabled', + }, + schedule: { + label: 'Schedule', + ready: '{{count}} plan(s) attached', + missing: 'No schedule attached', + }, + process: { + label: 'Process', + ready: 'Recent task used an agent', + missing: 'No agent-linked run yet', + }, + notify: { + label: 'Notify', + ready: '{{count}} notification rule(s)', + missing: 'No notification rule attached', + }, + records: { + label: 'Records', + ready: '{{count}} record(s) observed', + missing: 'No records observed yet', + }, + }, + errors: { + actionFailed: 'Action failed', + noExecutableAction: 'This node has no executable action', + actionUnavailable: 'This action is unavailable', + }, + }, + nodeActions: { + source: { + trigger: { + label: 'Trigger Collection', + description: 'Trigger one collection task for this source', + submitted: 'Collection submitted', + }, + open: { + label: 'Open Source', + description: 'Open the source list and focus this source', + }, + }, + task: { + trigger: { + label: 'Trigger Again', + description: 'Trigger one collection run from the task source ID', + missingSource: 'Task is missing source_id', + submitted: 'Collection submitted', + }, + open: { + label: 'View Details', + description: 'Open the tasks page', + }, + }, + agent: { + open: { + label: 'View Agent', + description: 'Open the agent configuration page', + }, + }, + errors: { + noExecutableAction: 'No executable action for {{actionId}}', + unknown: 'Unknown error', + }, + }, dashboard: { title: 'Dashboard', description: 'Real-time overview of your data collection system', @@ -60,6 +244,34 @@ const en: Translations = { recentRuns: 'Recent Task Runs', noRuns: 'No task runs yet', records: 'records', + runsToday: 'Runs Today', + todayOutcomeSummary: '{{success}} success / {{failed}} failed', + successRuns: 'Successful Runs', + failedRuns: 'Failed Runs', + successRate: 'Success Rate', + totalExecutions: '{{count}} executions', + ratio: '{{numerator}} / {{denominator}}', + noData: 'No data', + noChange: 'No change', + duration: 'Duration', + newRecords7d: 'New Records / 7D', + range: { + all: 'All', + today: 'Today', + yesterday: 'Yesterday', + '7d': '7D', + '30d': '30D', + custom: 'Custom', + }, + chart: { + title7d: 'Runs / 7D', + newRecords: 'New records', + total: { + total: 'Total runs', + success: 'Success', + failed: 'Failed', + }, + }, }, sources: { title: 'Data Sources', @@ -89,6 +301,18 @@ const en: Translations = { filterRunning: 'Running', filterCompleted: 'Completed', filterFailed: 'Failed', + steps: { + manual: 'Manual', + scheduled: 'Scheduled', + trigger: 'Trigger', + collect: 'Collect', + normalize: 'Normalize', + store: 'Store', + ai_process: 'AI Process', + notify: 'Notify', + complete: 'Complete', + failed: 'Failed', + }, }, records: { title: 'Collected Records', @@ -130,6 +354,12 @@ const en: Translations = { noRules: 'No notification rules', noLogs: 'No notification logs', ruleId: 'Rule ID', + recordId: 'Record ID', + deliveryStatus: 'Delivery', + ackStatus: 'ACK', + response: 'Response', + ackDetail: 'ACK Detail', + ackedAt: 'ACK Time', errorMsg: 'Error', time: 'Time', confirmDelete: 'Delete rule "{{name}}"?', @@ -143,6 +373,8 @@ const en: Translations = { webhookUrl: 'Webhook URL', secret: 'Signing Secret', webhookSecretHint: 'Optional. Used for HMAC-SHA256 request signing (X-Signature-256 header)', + ackSecret: 'ACK Secret', + ackSecretHint: 'Optional. Downstream ACK callbacks use the same HMAC header. Falls back to signing secret when empty.', extraHeaders: 'Extra Headers', dingtalkUrlHint: 'DingTalk robot webhook URL including access_token query param', dingtalkSecretHint: 'Optional. Fill when "Add Signature" is enabled on the robot', @@ -169,6 +401,13 @@ const en: Translations = { liveStats: 'Live Celery Stats', statsLoading: 'Loading…', notReachable: 'Celery not reachable (is the worker running?)', + statsUnavailableTitle: 'Celery is temporarily unreachable', + statsUnavailableDetail: 'The backend returned diagnostics; the worker list still uses the currently available data.', + localModeTitle: 'Running in local mode', + localModeDescription: 'Tasks are executed with local asyncio, so distributed worker status is hidden.', + localModeHint: 'For distributed deployment, set', + localModeSetTo: 'to', + localModeSuffix: ' and start the distributed task service.', workerId: 'Worker ID', host: 'Host', activeTasks: 'Active Tasks', diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 197c15f5..54a53a37 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -1,18 +1,31 @@ import i18n from 'i18next' import { initReactI18next } from 'react-i18next' -import zh from './zh' -import en from './en' +import { DEFAULT_LOCALE, LOCALE_KEY, i18nInitOptions } from './locales' +import { + getThemePreference, + applyThemePreference, + getSkinPreference, + applySkinPreference, + getDensityPreference, + applyDensityPreference, +} from '../lib/preferences' -const saved = localStorage.getItem('lang') ?? 'zh' +const savedLanguage = (() => { + try { + return localStorage.getItem(LOCALE_KEY) ?? DEFAULT_LOCALE + } catch { + return DEFAULT_LOCALE + } +})() -i18n.use(initReactI18next).init({ - resources: { - zh: { translation: zh }, - en: { translation: en }, - }, - lng: saved, - fallbackLng: 'zh', - interpolation: { escapeValue: false }, -}) +i18n.use(initReactI18next).init(i18nInitOptions(savedLanguage)) + +if (typeof document !== 'undefined') { + applyThemePreference(getThemePreference()) + applySkinPreference(getSkinPreference()) + applyDensityPreference(getDensityPreference()) +} + +export { LOCALE_KEY, DEFAULT_LOCALE } export default i18n diff --git a/frontend/src/i18n/locales.ts b/frontend/src/i18n/locales.ts new file mode 100644 index 00000000..2dbee99d --- /dev/null +++ b/frontend/src/i18n/locales.ts @@ -0,0 +1,77 @@ +import type { InitOptions } from 'i18next' +import type { Translations } from './zh' +import zh from './zh' +import en from './en' + +interface LocaleMeta { + code: string + label: string + nativeLabel: string + rtl: boolean + enabled: boolean + translation: Translations +} + +export const DEFAULT_LOCALE = 'zh' +export const LOCALE_KEY = 'lang' + +export const LOCALE_CATALOG: LocaleMeta[] = [ + { + code: 'zh', + label: '中文', + nativeLabel: '简体中文', + rtl: false, + enabled: true, + translation: zh, + }, + { + code: 'en', + label: 'English', + nativeLabel: 'English', + rtl: false, + enabled: true, + translation: en, + }, + { + code: 'ja', + label: '日本語', + nativeLabel: '日本語', + rtl: false, + enabled: false, + translation: en, + }, +] + +export const ENABLED_LOCALES = LOCALE_CATALOG.filter((item) => item.enabled) + +type LocaleResources = NonNullable + +export const LOCALE_RESOURCE_MAP: LocaleResources = ENABLED_LOCALES.reduce( + (acc, item) => { + acc[item.code] = { translation: item.translation } + return acc + }, + {} as LocaleResources, +) + +export function getLocaleMeta() { + return LOCALE_CATALOG +} + +export function getEnabledLocales(): LocaleMeta[] { + return ENABLED_LOCALES +} + +export function isEnabledLocale(code: string): code is string { + return ENABLED_LOCALES.some((item) => item.code === code) +} + +export function i18nInitOptions(initialLanguage: string): InitOptions { + return { + resources: LOCALE_RESOURCE_MAP, + lng: isEnabledLocale(initialLanguage) ? initialLanguage : DEFAULT_LOCALE, + fallbackLng: DEFAULT_LOCALE, + supportedLngs: ENABLED_LOCALES.map((locale) => locale.code), + interpolation: { escapeValue: false }, + } +} diff --git a/frontend/src/i18n/zh.ts b/frontend/src/i18n/zh.ts index 3d4d8985..545c21d3 100644 --- a/frontend/src/i18n/zh.ts +++ b/frontend/src/i18n/zh.ts @@ -1,18 +1,23 @@ const zh = { nav: { + home: '首页', dashboard: '仪表盘', + topology: '拓扑工作台', + workspace: '采集网络', + advanced: '高级 · 原始数据', sources: '数据源', tasks: '任务', records: '采集记录', schedules: '定时计划', agents: '智能体', providers: '模型提供商', - notifications: '通知', - workers: '工作节点', - browsers: '采集节点', - collapse: '收起', - dark: '深色', - light: '浅色', + notifications: '通知', + workers: '工作节点', + browsers: '采集节点', + settings: '设置', + collapse: '收起', + dark: '深色', + light: '浅色', }, common: { cancel: '取消', @@ -44,6 +49,185 @@ const zh = { createdAt: '创建时间', updatedAt: '更新时间', }, + brand: { + subtitle: '数据作业控制台', + opsConsole: '运维控制台', + }, + command: { + title: '命令面板', + placeholder: '跳转到记录、任务、节点…', + empty: '没有匹配的操作', + navigation: '导航', + openSettings: '打开设置', + footer: '输入关键词后回车打开', + }, + settings: { + title: '设置', + description: '集中管理界面偏好、主题和展示体验', + conversation: { + title: '对话式执行', + description: '在这里直接输入节点动作指令,系统按口令执行数据源/任务动作', + placeholder: '例如:trigger source 3f... 或 run task 3f... --chrome_endpoint=http://chrome-1', + tip: '支持 source/task 的触发类动作,也会解析 agent/task_id 参数', + run: '执行指令', + running: '执行中…', + failed: '执行失败', + errors: { + emptyInput: '请输入对话指令', + unsupportedType: '未识别指令类型,示例:trigger source 或 run task ', + missingEntity: '未识别实体 ID,示例:trigger source ', + unsupportedTarget: '当前目标类型暂未支持可执行动作', + unknown: '未知错误', + }, + }, + language: { + title: '语言', + description: '选择界面显示语言。当前仅支持中文/英文,预留后续更多语言。', + }, + theme: { + title: '主题', + description: '切换浅色/深色模式。设置会立刻生效并持久化。', + light: '浅色', + dark: '深色', + }, + density: { + title: '布局密度(预留)', + description: '列表和卡片视觉密度预留开关,先落地本地偏好位。', + compact: '紧凑', + comfortable: '舒适', + spacious: '宽松', + }, + experimental: { + title: '开发体验入口', + description: '保留快捷入口,方便你验证图谱/画布类视图行为。', + openTopology: '打开拓扑工作台', + openSources: '打开数据源工作台', + }, + reset: { + title: '开发调试', + description: '清空本地偏好键值(主题/语言/密度)并回到默认设置。', + label: '清空本地偏好', + }, + }, + topology: { + title: '拓扑工作台', + description: '从采集源到任务、智能体、记录和通知的运行关系', + focusDescription: '按数据源聚焦:{{name}}', + refresh: '刷新', + quickJump: '快速跳转', + allNodes: '全部节点', + running: '运行中', + needsFocus: '需要关注', + ready: '就绪', + missingSkills: '缺能力', + refreshing: '刷新中…', + edgeCount: '{{count}} 条连接', + selectNode: '选择一个节点查看上下文', + openDetail: '打开详情', + clearFocus: '清除聚焦', + modes: { + flow: '流程', + health: '健康', + skills: '能力', + }, + kinds: { + source: '数据源', + schedule: '计划', + task: '任务', + agent: '智能体', + record: '记录', + notification: '通知', + edgeNode: '边缘节点', + worker: '工作节点', + }, + health: { + healthy: '健康', + active: '运行中', + warning: '需关注', + failed: '失败', + disabled: '已停用', + unknown: '未知', + }, + inspector: { + skillMatrix: '能力矩阵', + skillSummary: '{{ready}} 就绪 · {{missing}} 缺失', + needsSkill: '需要能力', + ready: '就绪', + completeCapability: '补齐能力', + actions: '节点动作', + run: '运行', + done: '完成', + failed: '失败', + detail: '详情', + }, + skills: { + collect: { + label: '采集', + ready: '当前渠道可触发采集', + blocked: '数据源已停用', + }, + schedule: { + label: '计划', + ready: '已绑定 {{count}} 个计划', + missing: '尚未绑定采集计划', + }, + process: { + label: '处理', + ready: '最近任务已使用智能体', + missing: '尚无智能体运行记录', + }, + notify: { + label: '通知', + ready: '已绑定 {{count}} 条通知规则', + missing: '尚未绑定通知规则', + }, + records: { + label: '记录', + ready: '已观测 {{count}} 条记录', + missing: '尚未观测到记录', + }, + }, + errors: { + actionFailed: '动作执行失败', + noExecutableAction: '当前节点暂无可执行动作', + actionUnavailable: '此动作当前不可执行', + }, + }, + nodeActions: { + source: { + trigger: { + label: '触发采集', + description: '直接触发一次数据源采集任务', + submitted: '采集已提交', + }, + open: { + label: '打开源详情', + description: '跳转到数据源列表并聚焦该来源', + }, + }, + task: { + trigger: { + label: '再次触发', + description: '基于任务源 ID 触发一次采集', + missingSource: '任务缺少 source_id', + submitted: '采集已提交', + }, + open: { + label: '查看详情', + description: '跳转到任务页面', + }, + }, + agent: { + open: { + label: '查看智能体', + description: '跳转到智能体配置页', + }, + }, + errors: { + noExecutableAction: '没有可执行动作:{{actionId}}', + unknown: '未知错误', + }, + }, dashboard: { title: '仪表盘', description: '数据采集系统实时概览', @@ -58,6 +242,34 @@ const zh = { recentRuns: '最近任务运行', noRuns: '暂无任务运行记录', records: '条记录', + runsToday: '今日运行', + todayOutcomeSummary: '{{success}} 成功 / {{failed}} 失败', + successRuns: '成功运行', + failedRuns: '失败运行', + successRate: '成功率', + totalExecutions: '共 {{count}} 次执行', + ratio: '{{numerator}} / {{denominator}}', + noData: '暂无数据', + noChange: '无变化', + duration: '耗时', + newRecords7d: '近 7 日新增记录', + range: { + all: '全部', + today: '今天', + yesterday: '昨天', + '7d': '7 天', + '30d': '30 天', + custom: '自定义', + }, + chart: { + title7d: '近 7 日运行', + newRecords: '新增记录', + total: { + total: '总运行', + success: '成功', + failed: '失败', + }, + }, }, sources: { title: '数据源', @@ -87,6 +299,18 @@ const zh = { filterRunning: '运行中', filterCompleted: '已完成', filterFailed: '失败', + steps: { + manual: '手动', + scheduled: '定时', + trigger: '触发', + collect: '采集', + normalize: '归一化', + store: '入库', + ai_process: 'AI 处理', + notify: '通知', + complete: '完成', + failed: '失败', + }, }, records: { title: '采集记录', @@ -128,6 +352,12 @@ const zh = { noRules: '暂无通知规则', noLogs: '暂无投递日志', ruleId: '规则 ID', + recordId: '记录 ID', + deliveryStatus: '投递状态', + ackStatus: '回执状态', + response: '响应', + ackDetail: '回执详情', + ackedAt: '回执时间', errorMsg: '错误信息', time: '时间', confirmDelete: '确认删除规则 "{{name}}"?', @@ -141,6 +371,8 @@ const zh = { webhookUrl: 'Webhook URL', secret: '签名密钥', webhookSecretHint: '可选,用于 HMAC-SHA256 签名验证(X-Signature-256 请求头)', + ackSecret: '回执密钥', + ackSecretHint: '可选,下游回调 ACK 接口时使用同样算法签名;留空则复用签名密钥', extraHeaders: '自定义请求头', dingtalkUrlHint: '钉钉机器人 Webhook 地址,包含 access_token 参数', dingtalkSecretHint: '可选,开启加签后填写,与机器人加签密钥一致', @@ -167,6 +399,13 @@ const zh = { liveStats: 'Celery 实时统计', statsLoading: '加载中…', notReachable: 'Celery 不可达(工作节点是否已启动?)', + statsUnavailableTitle: 'Celery 暂时不可达', + statsUnavailableDetail: '后台已返回诊断信息,工作节点列表仍会按当前可用数据展示。', + localModeTitle: '单机模式运行中', + localModeDescription: '当前使用本地 asyncio 执行任务,不展示分布式工作节点状态。', + localModeHint: '如需分布式部署,请将', + localModeSetTo: '设为', + localModeSuffix: ' 并启动分布式任务服务。', workerId: '节点 ID', host: '主机', activeTasks: '活跃任务', diff --git a/frontend/src/index.css b/frontend/src/index.css index 1b8eb3c0..b00320dd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,6 +2,31 @@ @tailwind components; @tailwind utilities; +@font-face { + font-family: 'Source Han Sans Local'; + src: + local('Geist'), + local('Geist Sans'), + local('Source Han Sans CN'), + local('Source Han Sans SC'), + local('Source Han Sans'), + local('Noto Sans SC'), + local('Noto Sans CJK SC'); + font-display: swap; +} + +@font-face { + font-family: 'ToaHI Local'; + src: + local('Geist Mono'), + local('ToaHI-Rg'), + local('ToaHI Rg'), + local('Toa HI Rg'), + local('ToaHI-Regular'), + local('ToaHI'); + font-display: swap; +} + @keyframes fadeSlideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } @@ -10,58 +35,406 @@ animation: fadeSlideIn 0.2s ease-out; } +/* ── M3 motion: emphasized easing + standard durations ──────────────── */ +:root { + --m3-ease-emphasized: cubic-bezier(0.2, 0, 0, 1); + --m3-ease-decel: cubic-bezier(0.05, 0.7, 0.1, 1); /* enter */ + --m3-ease-accel: cubic-bezier(0.3, 0, 0.8, 0.15); /* exit */ + --m3-dur-short: 200ms; + --m3-dur-medium: 300ms; + --m3-dur-long: 450ms; +} +@keyframes m3SheetIn { + from { opacity: 0; transform: translateX(28px); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes m3LevelIn { + from { opacity: 0; transform: scale(0.985); } + to { opacity: 1; transform: scale(1); } +} +.m3-sheet-in { animation: m3SheetIn var(--m3-dur-medium) var(--m3-ease-decel) both; } +.m3-level-in { animation: m3LevelIn var(--m3-dur-long) var(--m3-ease-decel) both; } + +/* node-kit: collision feedback (xyflow getIntersectingNodes) */ +@keyframes kitCollide { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-2px); } + 75% { transform: translateX(2px); } +} +.react-flow__node.kit-collide { animation: kitCollide 0.16s ease-in-out infinite; } +.react-flow__node.kit-collide > div { + box-shadow: 0 0 0 2px rgba(244, 63, 94, 0.85), 0 0 18px rgba(244, 63, 94, 0.35); + border-radius: 10px; +} + @layer base { :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 221.2 83.2% 53.3%; + --background: 210 10% 3%; + --foreground: 0 0% 94%; + --card: 210 10% 5%; + --card-foreground: 0 0% 94%; + --popover: 210 10% 5%; + --popover-foreground: 0 0% 94%; + --primary: 214 100% 50%; --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; + --secondary: 210 9% 10%; + --secondary-foreground: 0 0% 88%; + --muted: 210 9% 10%; + --muted-foreground: 220 9% 61%; + --accent: 214 100% 50%; + --accent-foreground: 0 0% 98%; + --destructive: 3 84% 57%; --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 221.2 83.2% 53.3%; - --radius: 0.5rem; + --border: 220 9% 18%; + --input: 220 9% 18%; + --ring: 214 100% 50%; + --radius: 0.375rem; + --oc-bg: #050708; + --oc-surface: #0a0d10; + --oc-surface-raised: #101418; + --oc-line: rgba(143, 156, 169, 0.18); + --oc-line-strong: rgba(143, 156, 169, 0.32); + --oc-line-hot: rgba(47, 125, 246, 0.5); + --oc-text: #f5f7fa; + --oc-muted: #9aa4af; + --oc-muted-soft: #68727d; + --oc-primary: #2f7df6; + --oc-info: #4fb7d6; + --oc-gold: #d6a84f; + --oc-success: #35b779; + --oc-warning: #d99a3d; + --oc-danger: #e15b64; + --oc-violet: #9b7bf3; + --oc-ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --oc-ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1); + --font-ui: 'Source Han Sans Local', 'Geist', 'Geist Sans', 'Source Han Sans CN', 'Source Han Sans SC', 'Noto Sans SC', 'Noto Sans CJK SC', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC', 'Hiragino Sans GB', system-ui, sans-serif; + --font-telemetry: 'ToaHI Local', 'Geist Mono', 'ToaHI-Rg', 'ToaHI Rg', 'Source Han Sans Local', 'Source Han Sans CN', 'Noto Sans SC', 'Microsoft YaHei UI', system-ui, sans-serif; + --font-code: 'ToaHI Local', 'Geist Mono', 'ToaHI-Rg', 'ToaHI Rg', 'Cascadia Mono', 'SFMono-Regular', 'JetBrains Mono', ui-monospace, monospace; + } + + /* ── Design skins (data-skin on ) ─────────────────────────── */ + [data-skin='spacex'] { + --primary: 0 0% 82%; + --primary-foreground: 0 0% 8%; + --accent: 0 0% 82%; + --accent-foreground: 0 0% 8%; + --ring: 0 0% 72%; + --radius: 0; + --oc-bg: #000000; + --oc-surface: #050505; + --oc-surface-raised: #0c0c0c; + --oc-line: rgba(255, 255, 255, 0.14); + --oc-line-strong: rgba(255, 255, 255, 0.28); + --oc-line-hot: rgba(255, 255, 255, 0.55); + --oc-primary: #e4e4e7; + --oc-info: #d4d4d8; + } + + [data-skin='nvidia'] { + --primary: 82 100% 36%; + --primary-foreground: 0 0% 5%; + --accent: 82 100% 40%; + --accent-foreground: 0 0% 5%; + --ring: 82 100% 40%; + --oc-bg: #080b06; + --oc-surface: #0d1109; + --oc-surface-raised: #12170c; + --oc-line: rgba(118, 185, 0, 0.16); + --oc-line-strong: rgba(118, 185, 0, 0.32); + --oc-line-hot: rgba(118, 185, 0, 0.55); + --oc-primary: #76b900; + --oc-info: #8fd400; + --oc-success: #76b900; + } + + [data-skin='binance'] { + --primary: 45 91% 49%; + --primary-foreground: 0 0% 6%; + --accent: 45 91% 52%; + --accent-foreground: 0 0% 6%; + --ring: 45 91% 50%; + --oc-bg: #0b0e11; + --oc-surface: #12161b; + --oc-surface-raised: #181d23; + --oc-line: rgba(240, 185, 11, 0.16); + --oc-line-strong: rgba(240, 185, 11, 0.3); + --oc-line-hot: rgba(240, 185, 11, 0.5); + --oc-primary: #f0b90b; + --oc-gold: #f0b90b; + --oc-info: #f0b90b; + } + + [data-skin='spacex'] body { + background: #000000; + } + [data-skin='nvidia'] body { + background: #080b06; + } + [data-skin='binance'] body { + background: #0b0e11; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 224.3 76.3% 48%; + color-scheme: dark; } * { - @apply border-gray-200; + @apply border-zinc-800; } body { - @apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + @apply min-h-screen bg-[#070809] text-zinc-100 antialiased; + font-family: var(--font-ui); + letter-spacing: 0; + } + + code, + kbd, + pre, + .font-mono { + font-family: var(--font-code); + font-variant-numeric: tabular-nums; + } + + body::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 48px 48px; + opacity: 0.12; + } + + #root { + position: relative; + z-index: 1; + min-height: 100vh; + isolation: isolate; + } + + ::selection { + background: rgba(0, 110, 254, 0.35); + color: #fff; + } + + :focus-visible { + outline: none; + box-shadow: 0 0 0 2px #000, 0 0 0 4px #47a8ff; + } +} + +@layer components { + .mission-canvas { + background: + linear-gradient(180deg, #050505 0%, #080808 42%, #000 100%); + } + + .telemetry-panel { + position: relative; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)), + rgba(10, 10, 10, 0.92); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.05); + } + + .telemetry-panel::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.045) 1px, transparent 1px), + linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px); + background-size: 32px 32px; + opacity: 0; + } + + .telemetry-panel > * { + position: relative; + } + + .telemetry-label { + color: rgb(161 161 170); + font-family: var(--font-telemetry); + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .telemetry-value { + color: rgb(250 250 250); + font-family: var(--font-telemetry); + font-variant-numeric: tabular-nums; + letter-spacing: 0; + } + + .telemetry-button { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.035); + color: rgb(212 212 216); + font-family: var(--font-telemetry); + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; + } + + .telemetry-button:hover { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.075); + color: #fff; + } + + .telemetry-button[data-active='true'] { + border-color: rgba(0, 110, 254, 0.75); + background: rgba(0, 110, 254, 0.16); + color: #fff; + } + + .telemetry-input { + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + background: rgba(0, 0, 0, 0.35); + color: #fff; + font-family: var(--font-code); + font-size: 0.75rem; + } + + .operator-surface { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)), + rgba(10, 10, 10, 0.92); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + } + + .operator-card { + position: relative; + min-height: 7rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)), + rgba(0, 0, 0, 0.2); + padding: 0.75rem; + transition: + background-color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease; + } + + .operator-card:hover { + border-color: rgba(255, 255, 255, 0.22); + background-color: rgba(255, 255, 255, 0.04); + } + + .operator-card[data-active='true'] { + border-color: rgba(0, 110, 254, 0.65); + background-color: rgba(0, 110, 254, 0.075); + box-shadow: inset 0 0 0 1px rgba(0, 110, 254, 0.08); + } + + .operator-card__glyph { + display: grid; + height: 2.25rem; + width: 2.25rem; + flex-shrink: 0; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.035); + color: rgb(212 212 216); + } + +.live-run-surface-grid { +min-height: 680px; +} + +.live-run-surface-grid .react-grid-item { +transition: border-color 160ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 160ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.live-run-surface-grid .react-grid-item.react-draggable-dragging, +.live-run-surface-grid .react-grid-item.resizing { +z-index: 30; +box-shadow: 0 18px 48px rgba(0, 0, 0, 0.38); +} + +.live-run-surface-grid .react-grid-placeholder { +border: 1px solid rgba(0, 110, 254, 0.55); +border-radius: 6px; +background: rgba(0, 110, 254, 0.12); +opacity: 1; +} + +.live-run-surface-grid .react-resizable-handle::after { +border-color: rgba(161, 161, 170, 0.72); +} +} + +/* React Flow dark theme — UNLAYERED + specificity to beat react-flow's own style.css */ +.react-flow .react-flow__controls { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + overflow: hidden; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} + +.react-flow .react-flow__controls-button { + background: rgba(14, 14, 18, 0.96); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + fill: #d4d4d8; + color: #d4d4d8; + width: 26px; + height: 26px; +} + +.react-flow .react-flow__controls-button:hover { + background: rgba(40, 40, 48, 0.96); +} + +.react-flow .react-flow__controls-button svg { + fill: currentColor; +} + +.react-flow .react-flow__minimap { + background: rgba(8, 8, 11, 0.94); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; +} + +.react-flow .react-flow__minimap-mask { + fill: rgba(6, 6, 8, 0.7); +} + +.react-flow .react-flow__edge-text { + fill: #a1a1aa; + font-family: var(--font-code); + font-size: 10px; +} + +.react-flow .react-flow__edge-textbg { + fill: #050505; + fill-opacity: 0.85; +} + +.react-flow .react-flow__attribution { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } diff --git a/frontend/src/labs/topology/AGENT_DOCK_DESIGN.md b/frontend/src/labs/topology/AGENT_DOCK_DESIGN.md new file mode 100644 index 00000000..6e685e43 --- /dev/null +++ b/frontend/src/labs/topology/AGENT_DOCK_DESIGN.md @@ -0,0 +1,115 @@ +# Design: Agent 对话坞 (采集网络的改动入口) + +Generated by /office-hours on 2026-06-29 +Branch: master +Repo: opencli-admin (2233admin fork) +Status: DRAFT +Mode: Intrapreneurship (internal tooling) + +## Problem Statement + +Houdini 式只读「采集网络」(`/labs/topology`, `NetworkPage.tsx`) 已落地: 图只读、看清采集逻辑、双击钻入 scoped 子网。范式定死: **图只读,所有改动交给「跟 agent 对话,agent 落库实现」**。现在要建这个改动入口 — agent 对话坞。没有它,用户只能看不能改,只读视图自身价值有限。 + +## What Makes This Right + +把「改配置」从「点表单」变成「说人话」。用户说「停用 demo-binance-funding」,agent 提一个 diff,用户点确认,落库,图自动重画。画布负责"读",对话坞负责"写",职责干净。agent 复用后端已有的模型网关 + provider,不另起炉灶。 + +## Locked Decisions (本次对齐) + +- **D1 架构 = 后端 agent 坞**: agent 在服务端跑,复用现有 agents/providers/模型网关;写操作走现有 service 层校验;前端只当聊天壳。provider 密钥、写校验全留服务端。 +- **D2 范围 = 薄闭环**: v1 只做 1-2 个写动作端到端,把「意图→提议 diff→确认→落库→图重画」整条链路验证通,再按同一模式批量扩到全 CRUD。 +- **D3 楔子 = 启停 source**: v1 第一个写动作 = 切换 source 的 `enabled`。最简、可逆、爆炸半径最小;一句话跑完整套链路。 +- **聊天 UI 用轮子**: 不手搓。assistant-ui 或 Vercel AI SDK (`useChat`) 当壳。 + +## Premises (默认成立) + +1. 后端已有可复用的写端点/service: `updateSource`(PATCH enabled)、`triggerTask`、`updateSchedule` 等 — agent 工具直接包它们,不新写业务逻辑。 +2. **写前 diff 确认是硬底线** — agent 任何落库前必须先给前端一个「拟改动」让用户点确认。控制台动真数据,不确认不落库。 +3. agent 改完 → 复用现有 react-query refetch (`NetworkPage` 已有 `refetchAll`) → 图自动重画。 +4. 后端有 LLM 调用能力 (providers/模型网关已存在) 可被 chat 端点复用。 + +## Approaches Considered + +### 架构层 (D1) +- **A 后端 agent 坞** ✅选: 复用最多、密钥/校验留服务端、前端只壳。代价: 写后端 `/chat` 端点。 +- B 前端 copilot (CopilotKit): 最快 demo,但密钥/校验下沉前端,安全弱。 +- C MCP 工具坞: 最通用,但 infra 最重、过度工程。 + +### 范围层 (D2) +- **A 薄闭环 1-2 写动作** ✅选: 链路先通再铺广。 +- B 全 CRUD 一步到位: 首版重、回归风险高。 +- C 只读问答先行: 没写=没魂。 + +### 楔子层 (D3) +- **A 启停 source** ✅选: 可逆、爆炸半径最小。 +- B 触发 task: 真实运行副作用,不可逆。 +- C 改 cron: demo 源目前 0 schedule,得先造数据。 + +## Recommended Approach — v1 Thin Loop 规格 + +``` +[用户在对话坞输入] "停用 demo-binance-funding" + │ + ▼ 前端 chat 壳 (assistant-ui / AI SDK useChat) POST → 后端 +[后端 /chat 端点] + - 复用现有 provider/模型网关跑 LLM + - 工具集 (tool-calling): toggle_source(source_id, enabled) ← v1 只这一个写工具 + + list_sources / describe_node (只读上下文工具) + - LLM 决定调 toggle_source → 后端【不直接落库】,而是回一个 proposed_change: + { tool: "toggle_source", args: {...}, diff: "demo-binance-funding: enabled true → false" } + │ + ▼ +[前端渲染 diff 确认卡] ——— 用户点【确认】 ─→ POST /chat/confirm → 后端走现有 updateSource service → 落库 + └─ 用户点【取消】 ─→ 丢弃 + │ + ▼ +[确认成功] → 前端 refetchAll() → 采集网络图重画,节点 health 变灰 +``` + +### 组件分工 (轮子 vs 自写) +| 件 | 用什么 | 自写量 | +|---|---|---| +| 聊天 UI (消息流/输入框/流式) | **轮子**: assistant-ui 或 AI SDK `useChat` | 0 | +| diff 确认卡 | 自写小组件 (复用现有 Card/按钮) | 小 | +| 后端 `/chat` 端点 + tool-calling 循环 | 复用现有 provider 客户端 | 中 | +| `toggle_source` 工具 | 包现有 updateSource service | 小 | +| 对话坞↔画布联动 | 选中节点 id 作为 system 上下文注入 | 小 | +| 改完刷新 | 复用 `NetworkPage.refetchAll` | 0 | + +### 联动 +- 对话坞嵌在 `/labs/topology` 右侧 dock (或抽屉)。 +- 选中画布节点 → 把节点 kind/id/title 作为上下文喂给 agent (system message),用户说"停用它"agent 知道指代谁。 +- agent 确认落库成功 → 触发 `refetchAll` → 图重画。 + +## Open Questions + +1. 后端 `/chat` 端点用什么框架接 LLM tool-calling? 需先确认后端 provider 客户端是否已支持 function/tool calling,还是只裸文本补全(决定 tool-calling 是后端做还是前端 AI SDK 做)。**这是开建前唯一要先勘的点。** +2. 聊天壳最终选 assistant-ui 还是 AI SDK `useChat` — 看后端端点返回格式(AI SDK 要 SSE 流式协议;assistant-ui 更全但更重)。建建前快速 spike。 +3. diff 确认的"拟改动"协议: 后端回结构化 proposed_change,前端渲染 — 需定一个最小 schema。 + +## Success Criteria (v1) + +- 在 `/labs/topology` 对话坞输入「停用 demo-binance-funding」,agent 回一个可读 diff。 +- 点确认 → 真的落库 (`enabled=false`) → 采集网络图里该项目节点变灰。 +- 点取消 → 不落库。 +- provider 密钥从不出现在前端网络请求里。 + +## Distribution Plan + +内部工具,无独立分发。随 opencli-admin 前端 + 后端一起部署,现有 pipeline 覆盖。 + +## Next Steps (build order, 开建后) + +1. **勘后端**: 确认 backend provider 客户端是否支持 tool-calling + 现有 source PATCH 端点签名 (Open Question 1)。 +2. **轮子 spike**: 选 assistant-ui vs AI SDK,接一个最小 echo `/chat` 跑通流式。 +3. **后端 `/chat` 端点**: tool-calling 循环 + `toggle_source`/`list_sources`/`describe_node` 工具 + proposed_change 协议 (不直接落库)。 +4. **前端对话坞**: 聊天壳轮子 + diff 确认卡 + 嵌 `/labs/topology` dock + 选中节点上下文注入。 +5. **确认落库链路**: `/chat/confirm` → updateSource → 前端 refetchAll → 图重画。 +6. **端到端验证**: 停用/启用 demo-binance-funding 全程跑通。 +7. 验证通后,按同一 tool + confirm 模式批量扩 `trigger_task` / `update_schedule` / 全 CRUD。 + +## What I noticed + +- 你三次都选最稳的 A,但每次理由不是"保守"是"先验证链路/爆炸半径最小" — 这是工程判断,不是怕事。 +- "有轮子用轮子 别自己从零造" 你强调了至少四次。聊天壳坚决用轮子,自写只留 diff 确认卡和后端工具包装这两块没现成轮子的。 +- 范式定得很清: 图只读 / 对话写。这个职责切分让 scale 和安全都好办 — 写全集中在一条带确认的链路上。 diff --git a/frontend/src/labs/topology/AgentDock.tsx b/frontend/src/labs/topology/AgentDock.tsx new file mode 100644 index 00000000..8f51c60e --- /dev/null +++ b/frontend/src/labs/topology/AgentDock.tsx @@ -0,0 +1,339 @@ +import { useRef, useState, type KeyboardEvent } from 'react' +import { Bot, Check, Loader2, RefreshCw, Send, Sparkles, User, X } from 'lucide-react' +import { toast } from 'sonner' + +import { apiClient } from '../../api/client' +import type { ApiResponse } from '../../api/types' +import { cn } from '../../lib/utils' + +/* Agent 对话坞 — 采集网络的改动入口。 + * 范式: 图只读, 改动跟 agent 说。agent (后端复用 provider/模型网关 + tool-calling) + * 决定调工具; 写工具不直接落库, 回一个 proposal, 用户在这里点确认才走 /chat/confirm 落库。 + * 聊天壳目前用现有 primitives + 纯文本渲染 (后端协议是简单 JSON, 之后可平滑换 assistant-ui)。 */ + +interface ChatMsg { + role: 'user' | 'assistant' + content: string +} + +interface Proposal { + tool: string + args: Record + summary: string + diff: string +} + +interface ChatReply { + type: 'message' | 'proposal' + content?: string | null + proposal?: Proposal | null +} + +export interface DockContextNode { + kind: string + id: string + title: string +} + +/* A re-distill target: the failing skill + its journey_trace_v1 trace. Surfaced + * when the dock's context is a failing skill (kind === 'skill', optionally with a + * run's self_eval.passed === false). Reuses the proposal→confirm contract — the + * 重蒸技能 button shows the SAME amber confirm card, and on confirm POSTs to + * /skills/{id}/redistill (re-distillation, never an auto-trigger). */ +interface RedistillTarget { + skillId: string + title: string + trace: Record +} + +const GREETING: ChatMsg = { + role: 'assistant', + content: '我是采集网络助手。想看懂某条采集逻辑、或要改配置(如启停数据源), 直接跟我说。改动会先给你确认再落库。', +} + +export function AgentDock({ + contextNode, + onApplied, + failingTrace = null, +}: { + contextNode: DockContextNode | null + onApplied: () => void + /* Optional failing journey_trace_v1 from a run, passed by the parent run view. + * When the context is a skill and a trace is available, the 重蒸技能 action can + * re-distill it. Absent → a minimal context-only trace is used so the + * human-triggered flow is still exercisable. */ + failingTrace?: Record | null +}) { + const [messages, setMessages] = useState([GREETING]) + const [input, setInput] = useState('') + const [loading, setLoading] = useState(false) + const [proposal, setProposal] = useState(null) + const [redistill, setRedistill] = useState(null) + const scrollRef = useRef(null) + + const scrollToBottom = () => { + requestAnimationFrame(() => { + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }) + } + + const append = (msg: ChatMsg) => { + setMessages((prev) => [...prev, msg]) + scrollToBottom() + } + + const send = async () => { + const text = input.trim() + if (!text || loading) return + const userMsg: ChatMsg = { role: 'user', content: text } + const history = [...messages.filter((m) => m !== GREETING), userMsg] + append(userMsg) + setInput('') + setLoading(true) + setProposal(null) + try { + const reply = await apiClient + .post>('/chat', { + messages: history.map((m) => ({ role: m.role, content: m.content })), + context: contextNode ?? undefined, + }) + .then((r) => r.data.data) + + if (reply.type === 'proposal' && reply.proposal) { + setProposal(reply.proposal) + append({ role: 'assistant', content: `我想${reply.proposal.summary}。\n变更: ${reply.proposal.diff}\n确认后才会落库。` }) + } else { + append({ role: 'assistant', content: reply.content || '(无内容)' }) + } + } catch (err) { + const detail = extractDetail(err) + append({ role: 'assistant', content: `出错了: ${detail}` }) + } finally { + setLoading(false) + } + } + + const confirm = async () => { + if (!proposal || loading) return + setLoading(true) + try { + await apiClient.post('/chat/confirm', { proposal }) + toast.success(`已${proposal.summary}`) + append({ role: 'assistant', content: `✅ 已${proposal.summary}, 图已刷新。` }) + setProposal(null) + onApplied() + } catch (err) { + const detail = extractDetail(err) + toast.error(`落库失败: ${detail}`) + append({ role: 'assistant', content: `❌ 落库失败: ${detail}` }) + } finally { + setLoading(false) + } + } + + const cancel = () => { + setProposal(null) + append({ role: 'assistant', content: '已取消, 没有改动。' }) + } + + /* 重蒸技能 — re-distill a failing skill. Reuses the SAME proposal→confirm + * contract: clicking opens an amber confirm card (it does NOT auto-fire), and + * on confirm POSTs the failing trace to /skills/{id}/redistill. */ + const proposeRedistill = () => { + if (!contextNode || contextNode.kind !== 'skill' || loading) return + const trace: Record = failingTrace ?? { + schema: 'journey_trace_v1', + trace_id: `dock-${contextNode.id}`, + label: contextNode.title, + summary: { domain: 'unknown' }, + steps: [], + outcome: { status: 'failed', milestones_hit: [], terminal_check: false }, + } + setProposal(null) + setRedistill({ skillId: contextNode.id, title: contextNode.title, trace }) + } + + const confirmRedistill = async () => { + if (!redistill || loading) return + setLoading(true) + try { + const res = await apiClient + .post>(`/skills/${redistill.skillId}/redistill`, { + trace: redistill.trace, + }) + .then((r) => r.data.data) + toast.success(`已重蒸技能「${redistill.title}」→ v${res.version}`) + append({ role: 'assistant', content: `✅ 已重蒸技能「${redistill.title}」, 新版本 v${res.version}。` }) + setRedistill(null) + onApplied() + } catch (err) { + const detail = extractDetail(err) + toast.error(`重蒸失败: ${detail}`) + append({ role: 'assistant', content: `❌ 重蒸失败: ${detail}` }) + } finally { + setLoading(false) + } + } + + const cancelRedistill = () => { + setRedistill(null) + append({ role: 'assistant', content: '已取消重蒸, 技能未改动。' }) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + void send() + } + } + + return ( +
+ {/* header */} +
+ + + +
+

AGENT 对话坞

+

改动入口 · 写前确认

+
+ {contextNode && ( + + @ {contextNode.title} + + )} + {contextNode?.kind === 'skill' && ( + + )} +
+ + {/* messages */} +
+ {messages.map((m, i) => ( +
+ + {m.role === 'user' ? : } + +
+ {m.content} +
+
+ ))} + {loading && ( +
+ 思考中… +
+ )} +
+ + {/* 重蒸技能 confirm card — same amber confirm contract as proposals */} + {redistill && ( +
+

待确认重蒸

+

重新蒸馏技能「{redistill.title}」→ version n+1

+

用失败轨迹重蒸, 旧版本保留, 确认后生成新版本。

+
+ + +
+
+ )} + + {/* proposal confirm card */} + {proposal && ( +
+

待确认改动

+

{proposal.summary}

+

{proposal.diff}

+
+ + +
+
+ )} + + {/* composer */} +
+
+